42 -代码精进之路

开篇词 | 你写的每一行代码，都是你的名片

你好，我是范学雷，现在是 Oracle 的主任工程师，也是 OpenJDK 和 Java 安全的评审成员。很高兴和你一起聊聊怎么写好代码这个话题。

我第一次接触计算机，是在 1994 年。那时候，我还是大学一年级的一枚青瓜。当时的计算机发展，正处于青涩的少年阶段。“互联网”也还是一个非常生僻的名词。当时，我们用的计算机是“286”，操作系统是 DOS，编程语言还是 Fortran 和 C 语言，Java 语言还没有正式诞生。每次上课，都要随身携带容量为 360KB 的 5.25 英寸软盘。娇气的软盘啊，可是不好伺候，动不动就损坏。那时候最渴望的事情，就是能有一张存储容量高达 1.44MB 的高密度 3.5 英寸软盘。

计算机启蒙课给我印象最深的是什么呢？不是怎么写程序，而是不停地折腾软盘，一直重复“修复、备份”这个过程。也许是因为软盘的拷贝和修复太无聊，我一直对计算机以及编程没有特别大的兴趣。

但大学最后一年，两件小事让我改变了对计算机和编程的态度，给我带来了巨大的影响。

第一件事是，我一个同学编写的五子棋人机对弈程序，当时打遍全班无敌手。厉害吧！用现在的话说，就是“怎么可以这么炸”！这可不是使蛮力，用穷举法就可以搞定的，到底是怎么做到的？这引起了我对计算机程序的强烈兴趣。

第二件事是，我另一个同学的毕业论文选择了密码学作为研究方向。这个同学有一个优点，不管什么事情，都特别喜欢分享。用东北话说，就是爱嘚瑟，逢人便絮叨。最后差不多全班都知道了密码学的一些基本概念，了解了与之相关的好多传奇故事。密码学这种超神秘、超有趣、超复杂的存在，简直吊足了我的胃口。

我们的每一次经历，都塑造着我们自己。写人机对弈程序的同学，第一次面试就找好了工作，进了最好的公司。研究密码学的同学，是中国商业密码产业化最早的参与者之一。而我自己呢，在他们的影响下，也找到了计算机的乐趣，享受着解决复杂问题带来的喜悦。

编程和密码学这两个东西合在一起，就是我现在每天工作的主要内容。具体来说就是两件事：写代码和看代码。

写代码这件事，就我自己的经历来说，有点像过山车。

我刚开始学习编程时，写几十行代码都觉得痛苦、费劲，不知道从哪儿下手。这种状况一直持续了很多年，直到 1998 年我参加工作，编写程序成了我的职业。职业也就意味着，编码有了具体的目标，代码有了具体的质量要求。

我是幸运的。目标，有人掰碎了、揉烂了给我讲；质量，有人睁大了眼睛盯着看，也有人不顾情面地给我指出各种问题。有了目标就有了思路，有了要求就有了动力。如果再有人不离不弃地帮助，每一个度日如年的煎熬，最终都会变成“士别三日”的惊喜。慢慢地，我就可以写几百行、几千行、几万行甚至十几万行的代码了。而且越写越快，越写越好。

大概到了 2000 年的时候，代码设计对我来说可能依然很费时间，但是只要写起代码来，一天数千行也是很常见的。一天洋洋洒洒写数千行代码，暗暗觉得自己挺牛，挺了不起的。

“无知要比知识更容易产生自信”。幸运的是，这种盲目的自信没有持续太久，我很快就见识到了更宽阔的世界。2004 年，我加入了 Java 安全组，真正地见识到了，优秀的设计和优秀的代码，是怎么一步一个脚印地出炉的，了解到代码背后的各种综合考量和艰难取舍。慢慢地，我自己也完成了从“代码数量优先”到“代码质量优先”的思路转变。

如果回头看十多年前编写的代码，就像是看筛子一样，到处都是清清楚楚的破洞。也许，这是每个程序员都要经历的过程吧。

我们总是先要解决掉数量问题，然后才能解决掉质量问题。

这个过程，还真的有点“看山是山，看山不是山，看山还是山”的味道。

看代码这件事，对我来说，其实是一个收获大于付出的过程。

OpenJDK 的代码必须通过评审才可以提交。OpenJDK 社区有非常广泛的代码贡献群体，有些是还没有毕业的年轻学生，也有些资深的业界老专家。新手当然有新手的困惑，而老辣的程序员，也会犯简单的错误。

一个代码评审者的主要工作，不是批准或者拒绝提交的代码，而是提出合理的建议，帮助代码提交者规避这些失误或者错误，编写出更优秀的代码。

看代码看得多了，对代码就有更多的了解。 比如，什么样的代码更容易出问题？ 什么样的代码会招惹麻烦？ 什么样的代码出力不讨好？ 什么样的代码小问题闯大祸？

同时，也对程序员有了更多的了解。 比如，为什么我们不愿意写注释呢？ 为什么代码写完就不愿意修改了呢？为什么我们不愿意做测试呢？ 为什么我们向往自由而不愿意遵守规范呢？

每一行代码，都体现着程序员的修为，思考问题的深度，甚至是处理问题的习惯和态度。代码，是我们交流的语言和处世的名片。

这些问题，思考总结下来，就是代码评审的经历馈赠给我的礼物，而且是天大的礼物。

现在我把这份礼物沉淀下来，就是我们这个专栏的主要内容。我想通过这样一个专栏，让你拥有和我一样的收获。

回顾我这二十多年的程序员经历，我觉得自己是非常幸运的。现在，我们常常调侃“35 岁码农大龄恐惧症”。幸运的是，当这种病毒一般的焦虑开始流传开来的时候，我早已经过了 35 岁，已经来不及担心了。

这种焦虑之所以广泛流传，背后传达的一个本质问题就是：作为一名软件工程师，我们该怎么快速成长，并且保持长久的竞争力？

解决这个问题的终极方法，只有一个，那就是持续地交付优秀的结果。宜早不宜迟。

作为解决现实问题的软件工程师，不管资历深、资历浅，我们都需要编写优秀的代码，并且是越来越优秀的代码，因为这是我们生存的基本依靠。作为活在现实世界的技术工程师，我们需要保持长久的竞争力，甚至是越来越强的竞争力，因为这是改善我们生存质量的最好方式。

在这个专栏里，我会带着你开始一段代码精进的旅程。和你一起来看一看、摸一摸那些年别人踩过的坑，来聊一聊、试一试我们的代码可以写得有多棒，享受这个打怪升级的过程。

那么现在，给你一个机会，你敢不敢吐槽一下你见过的或者写过的，最“差劲儿”的代码？或者，你愿不愿意秀秀你自己最中意的代码？

也欢迎你在留言区写下自己的编程故事，等到专栏结束后，我们再回过头来，看看你走出了怎样的成长轨迹。

我渴望做那些伟大而高贵的任务，但是，我首要的责任和快乐却是去完成那些卑微的任务，把它们也当作伟大而且高贵的一样。世界在前行，不只是那些英雄们的力量在推动，也同样包括那些来自每个诚实的工作者微小推动的积累。—— 海伦•凯勒

01 | 从条件运算符说起，反思什么是好代码

写出优秀的代码是我们每一个程序员的毕生追求，毕竟写代码本身就是个技术活，代码的好坏，其实也就是我们工艺的好坏。作为一个技术类的工种，我们没有理由不去思考如何写出优秀、让人惊叹的代码。

那什么样的代码才是优秀的代码呢？对于这个问题，我想每个人心中都会有自己的答案。今天我就来和你聊聊我的思考。

对于条件运算符（?:）的使用，我估摸着你看到过相关的争论，或者自己写代码的时候也不知道到底该不该使用条件运算符，或者什么情况下使用？这些微不足道的小话题随时都可以挑起激烈的争论。

C 语言之父丹尼斯·里奇就属于支持者。在《C 程序设计语言》这本书里，他使用了大量简短、直观的条件运算符。

然而还有一些人，对条件运算符完全丧失了好感，甚至把“永远不要使用条件运算符”作为一条 C 语言高效编程的重要技巧。

比如说吧，下面的这个例子，第一段代码使用条件语句，第二段代码使用条件运算符。 你觉得哪一段代码更“优秀”呢？

if (variable != null) {

   return variable.getSomething();

}

return null;

return variable != null ? variable.getSomething() : null;

同样使用条件运算符，你会喜欢下面代码吗？

return x >= 90 ? "A" : x >= 80 ? "B" : x >= 70 ? "C" : x >= 60 ? "D" : "E";

十多年前，作为一名 C 语言程序员，我非常喜欢使用条件运算符。因为条件运算符的这种压缩方式，使代码看起来简短、整洁、干净。 而且，如果能把代码以最少的行数、最简短的方式表达出来，心里也颇有成就感。

后来，我的一位同事告诉我，对于我使用的条件运算符的部分代码，他要仔细分析才知道这一小行代码想要表达的逻辑，甚至有时候还要翻翻书、查查操作符的优先级和运算顺序，拿笔画一画逻辑关系，才能搞清楚这一小行代码有没有疏漏。

这么简单的代码，为什么还要确认运算符的优先级和运算顺序呢？因为只是“看起来”对的代码，其实特别容易出问题。所以，一定要反复查验、确认无误才能放心。

这么简单的代码，真的需要这么认真检查吗？超级简单的代码的错误，往往是我们最容易犯的一类编码错误。我个人就是犯过很多次这种低级、幼稚的错误，并且以后一定还会再犯。比如下面的这段有问题的代码，就是我最近犯的一个非常低级的代码错误：

// Map for debug logging.  Enable debug log if SSLLogger is on.

private final Map<Integer, byte[]> logMap =

        SSLLogger.isOn ? null : new LinkedHashMap<>();

正确的代码应该是：

// Map for debug logging. Enable debug log if SSLLogger is on.

private final Map<Integer, byte[]> logMap =

        SSLLogger.isOn ? new LinkedHashMap<>() : null;

你可能会说，这个代码错误看起来太幼稚、太低级、太可笑了吧？ 确实是这样的。这段错误的代码，我的眼睛不知道看过了它们多少次，可是这个小虫子（bug）还是华丽丽地逃脱了我的注意，进入了JDK 11 的最终发布版。

如果使用条件语句，而不是条件运算符，这个幼稚错误发生的概率会急剧下降。 坚持使用最直观的编码方式，而不是追求代码简短，真的可以避免很多不必要的错误。所以说啊，选择适合的编码方式，强调代码的检查、评审、校验，真的怎么都不算过分。

现在，如果你要再问我喜欢哪种编码方式，毫无疑问，我喜欢使用条件语句，而不是条件运算符。因为，用条件语句这种编码方式，可以给我确定感，我也不需要挑战什么高难度动作；而看代码的人，也可以很确定，很轻松，不需要去查验什么模糊的东西。

这种阅读起来的确定性至少有三点好处，第一点是可以减少代码错误；第二点是可以节省我思考的时间；第三点是可以节省代码阅读者的时间。

减少错误、节省时间，是我们现在选择编码方式的一个最基本的原则。

《C 程序设计语言》这本 C 程序员的圣经，初次发表于 1978 年。那个年代的代码，多数很简单直接。简短的代码，意味着节省昂贵的计算能力，是当时流行的编码偏好。而现在，计算能力不再是瓶颈，如何更高效率地开发复杂的软件，成了我们首先需要考虑的问题。

有一些新设计的编程语言，不再提供条件运算符。 比如，Kotlin 语言的设计者认为，编写简短的代码绝对不是 Kotlin 的目标。所以，Kotlin 不支持条件运算符。 Go 语言的设计者认为，条件运算符的滥用，产生了许多难以置信的、难以理解的复杂表达式。所以，Go 语言也不支持条件运算符。

我们看到，现实环境的变化，影响着我们对于代码“好”与“坏”的判断标准。

“好”的代码与“坏”的代码

虽然对于“什么是优秀的代码“难以形成一致意见，但是这么多年的经验，让我对代码“好”与“坏”积累了一些自己的看法。

比如说，“好”的代码应该：

容易理解；

没有明显的安全问题；

能够满足最关键的需求；

有充分的注释；

使用规范的命名；

经过充分的测试。

“坏”的代码包括：

难以阅读的代码；

浪费大量计算机资源的代码；

代码风格混乱的代码；

复杂的、不直观的代码；

没有经过适当测试的代码。

当然，上面的列表还可以很长很长，长到一篇文章都列不完、长到我们都记不住的程度。

优秀的代码是“经济”的代码

大概也没人想记住这么多条标准吧？所以，关于优秀代码的特点，我想用“经济”这一个词语来表达。这里的“经济”，指的是使用较少的人力、物力、财力、时间、空间，来获取较大的成果或收益 。或者简单地说，投入少、收益大、投资回报高。为了方便，你也可以先理解为节俭或者抠门儿的意思。

当然，使用一个词语表达肯定是以偏概全的。但是，比起一长串的准则，一个关键词的好处是，更容易让人记住。我想这点好处可以大致弥补以偏概全的损失。

该怎么理解“经济”呢？这需要我们把代码放到软件的整个生命周期里来考察。

关于软件生命周期，我想你应该很熟悉了，我们一起来复习一下。一般而言，一个典型的软件生命周期，大致可以划分计划、分析和设计、代码实现、测试、运营和维护这六个阶段。在软件维护阶段，可能会有新的需求出现、新的问题产生、旧问题的浮现，这些因素可能就又要推动新一轮的计划，分析、设计、实现、测试、运营。这样，这个周期就会反复迭代，反复的循环，像一个周而复始的流水线。

当我们说投入少的时候，说的是这整个生命周期，甚至是这个周而复始的生命周期的投入少。 比如说，代码写得快，可是测试起来一大堆问题，就不是经济的。

现代的大型软件开发，一般都会有比较细致的分工，在各个阶段参与的人是不同的；甚至在相同的阶段，也会有多人参与。一个稍有规模的软件，可能需要数人参与设计和实现。而为了使测试相对独立，软件测试人员和软件实现人员也是相对独立的，而且他们具备不同的优势和技能。

所以，当我们考虑投入的时候，还要考虑这个生命周期里所有的参与人员。这些参与人员所处的立场、看问题的角度，所具有的资源禀赋，可能千差万别。比如说，如果客户需要阅读代码，才知道系统怎么使用，就不是经济的。

是不是所有的软件都有这六个阶段呢？显然不是的，我本科的毕业论文程序，就完全没有运营和维护阶段，甚至也不算有测试阶段。我当时的毕业论文是一个关于加快神经网络学习的数学算法。只要验证了这个算法收缩得比较快，程序的使命就完成了，程序就可以退出销毁了。 所以，运营和维护阶段，甚至测试阶段，对当时的我而言，都是不需要投入的阶段。

在现代商业社会里，尤其我们越来越倾向于敏捷开发、精益创业，提倡“快速地失败、廉价地失败”，很多软件走不到维护阶段就已经结束了。而且，由于人力资源的限制，当然包括资金的限制，一个程序员可能要承担很多种角色，甚至从开始有了想法，到软件实现结束，都是一个人在战斗，哪里分什么设计人员、测试人员。

对软件开发流程选择的差异，就带来了我们对代码质量理解，以及对代码质量重视程度的千差万别。 比如说，一个创业公司是万万不能照搬大型成熟软件的开发流程的。因为，全面的高质量、高可靠、高兼容性的软件可能并不是创业公司最核心的目标。如果过分纠缠于这些代码指标，创始人的时间、投资人的金钱可能都没有办法得到最有效的使用。

当然，越成熟的软件开发机制越容易写出优秀的代码。但是，最适合当前现实环境的代码，才是最优秀的代码。

所以，当我们考虑具体投入的时候，还要考虑我们所处的现实环境。 如果我们超出现实环境去讨论代码的质量，有时候会有失偏颇，丧失我们讨论代码质量的意义。

既然具体环境千差万别，那我们还有必要讨论什么是优秀的代码吗？优秀的代码还能有什么共同的规律吗？ 即使一个人做所有的事情，即使代码用完一次就废弃，我们长期积累下来的编写优秀代码的经验，依然可以帮助到很多人。

比如说，虽然创业公司的软件刚开始最核心的追求不是全面的高可靠性。可是，你也要明白，创业的目的不是为了失败，一旦创业公司稳住了阵脚，这个时候如果它们没有高可靠性的软件作为支撑，很快就会有反噬作用。 而程序员背锅，就是反噬的其中一个后果。

如何使用最少的时间、最少的资源，提供最可靠的软件，什么时候开始把可靠性提高到不可忽视的程度，有没有可能一开始就是高可靠的, 这些就都是一个富有经验的创业公司技术负责人不得不考虑的问题。而我们总结出来的编写代码的经验，毫无疑问，可以为这些问题提供一些思路和出路。

为什么我们要从“经济”这个角度来衡量优秀的代码呢？ 因为这是一个可以让我们更加理性的概念。

一个营利性的公司，必须考虑投入产出比，没有人愿意做亏本的买卖，股东追求的是利润最大化。作为程序员，我们也必须考虑投入和产出。 首先，我们的产出必须大幅度大于公司对我们的投入，否则就有随时被扫地出门的风险。然后，我们必须使用好我们的时间，在单位时间内创造更多的价值，否则，真的是没有功劳，只有徒劳。

编写代码的时候，如果遇到困惑或者两难，你要想一想，怎么做才能做到投资少、收益大？

即便具体环境千差万别，我还是有一些例子，可以和你一起分享：

代码写得又快又好，是“经济”的；代码写得快，但是错误多，不是一个“经济”的行为。

代码跑得又快又好，是“经济”的；代码跑得快，但是安全问题突出，不是一个“经济”的行为。

代码写得精简易懂，是“经济”的；代码写得精简，但是没人看得懂，不是一个“经济”的行为。

总结

对于所有的程序员来说，每个人都会遇到两个有名的捣蛋鬼，一个捣蛋鬼是“合作”，另一个捣蛋鬼是“错误”。

要合作，就需要用大部分人都舒服的方式。程序员间合作交流最重要的语言便是代码，换句话说，这就需要我们规范地编写代码，使用大家都接受的风格。不规范的代码，我们可能节省了眼前的时间，但是测试、运营、维护阶段，就需要更多的时间。而一旦问题出现，这些代码会重新返工，又回到我们手里，需要阅读、修改，再一次浪费我们自己的时间。对于这些代码，每一点时间的付出，都意味着投入，意味着浪费，意味着我们损失了做更有意义事情的机会。

人人都会犯错误，代码都会有 bug，可是有些错误的破坏力是我们无法承受的，其中，最典型的就是安全问题。很多安全问题对公司和个人造成不容忽视的恶劣影响。我见过因为安全问题破产的公司。这时候，甚至都不要谈什么投入产出比、经济效益了，所有的投入归零，公司破产，员工解散。这需要我们分外地卖力，拿出十二分的精神来处理潜在的安全威胁，编写安全的代码。

如果我们把规范和安全作为独立的话题，优秀的代码需要具备三个特征： 经济、规范、安全。这些内容就是我们接下来要在专栏里一起学习的主体。

好了，今天我们一口气聊了很多，主要是在探讨到底什么样的代码才是优秀的代码。这个问题你之前考虑过吗？和我今天讲的是否一样呢？欢迎你在留言区写写自己的想法，我们可以进一步讨论。也欢迎你把今天的文章分享给跟你协作的同学，看看你们之间的理解是否一致。

02 | 把错误关在笼子里的五道关卡

上一讲中，我们一起讨论了什么是优秀的代码。简而言之，优秀的代码是经济、规范、安全的代码。在平时的工作中，我们要朝着这个方向努力，时常站在团队、流程、个人能力的角度去思考优秀代码。

作为一名软件工程师，我们都想写出优秀的代码。可是，怎么才能编写出经济、规范、安全的代码呢？这是个大话题，相信你之前也有过思考。

无心的过失

开始之前，我先给你讲个曾经发生过的真实案例。2014 年 2 月，苹果公司的 iOS 和 OS X 操作系统爆出严重的安全漏洞，聪明的黑客们可以利用这一漏洞，伪装成可信网站或者服务，来拦截用户数据。而造成这一漏洞的原因，也让业界专家大跌眼镜。

下面我用 C 语言的伪代码来给你简单描述下当时的漏洞情况。

    if ((error = doSomething()) != 0)

        goto fail;

        goto fail;

    if ((error= doMore()) != 0)

        goto fail;

fail:

    return error;

其实这段代码非常简单，它有两个判断语句，如果判断条件成立，那就执行“goto fail”语句，如果不成立，那就跳过判断语句继续执行。上面的“goto fail”语句，它的意思是略过它之后的所有语句，直接跳转到标有“fail”语句的地方，也就是第 6 行。

我们来分析下，第一个判断条件（第一行和第二行），如果 error 不等于零，那就跳转到 fail 语句，这逻辑上没什么问题。而第三行，没有任何附加条件，就能直接跳转到 fail 语句，也就是说，它下面的代码永远也无法执行，这里是不是有问题？是的，漏洞就是出在这里。

这一行多余的代码就是导致苹果操作系统那个安全漏洞的罪魁祸首。2014 年 2 月 21 日，苹果发布了相关的安全补丁，你随便一搜“GoTo Fail 漏洞”就能找到相关的细节，我这里不赘述了。

我们每天仰慕的苹果操作系统出现这样“低级”的错误，你是不是有点惊讶？这么一个“简单”的错误，引发了一个非常严重的安全漏洞，是不是也有点出乎意料？上面的错误，简单看，就是复制的时候多复制了一行，或者因为时间关系，或者因为粗心大意，苹果的工程师硬是没检查出来。这在我们平时的工作中，也经常出现。

这个具有重大杀伤力的 bug 是如此的“幼稚”，如此的“好玩”，如此的“萌萌哒”，以至于到现在，人们还可以买到印有“GoTo Fail”的 T 恤衫，更别提业界对于这个问题的兴趣了。有很多文章，专门研究这一个“低级”安全漏洞；甚至有人探讨这个“低级”错误对于计算机软件教育的积极影响。

所有的危机都不应该被浪费，这一次也不例外。这些年，我也一直在思考为什么我们会犯如此“低级”的错误？即使是在苹果这样的大公司。反过来再想，我们应该如何尽可能避免类似的错误呢？

人人都会犯错误

没有人是完美的，人人都会犯错误。这应该是一个共识。这里面既有技术层面的因素，也有人类的行为模式的因素，也有现实环境的影响。我们在此不讨论人类进化和心智模式这样的严肃研究成果。但是，有两三个有意思的话题，我想和你聊聊。

第一个比较普遍的观点是好的程序员不会写坏的代码，要不然，就是他还不足够优秀。我尊重这个观点背后代表的美好愿望，但是这个观点本身我很难认同。它一定程度上忽视了人类犯错误的复杂性，和影响因素的多样性。

我认为，即使一个非常优秀的程序员，他主观上非常认真，能力又非常强，但他也会犯非常“低级”、“幼稚”的错误。所以，你不能因为苹果那个程序员，犯了那个非常低级的错误，就一棒子把他“打死”，认为他不是一个好的程序员。

第二个更加普遍的观点是同样的错误不能犯第二次。作为一名程序员，我同样尊重这个观点背后代表的美好期望。但是，我想给这个观点加一点点限制。这个观点应该是我们对自身的期望和要求；对于他人，我们可以更宽容；对于一个团队，我们首先要思考如何提供一种机制，以减少此类错误的发生。如果强制要求他人错不过三，现实中，我们虽然发泄了怨气，但是往往错失了工作机制提升的机会。

第三个深入人心的观点是一个人犯了错误并不可怕，怕的是不承认错误。同样的，我理解这个观点背后代表的美好诉求。这是一个深入人心的观点，具有深厚的群众基础，我万万不敢造次。在软件工程领域，我想，在犯错这件事情上，我们还是要再多一点对自己的谅解，以及对他人的宽容。错误并不可怕，你不必为此深深自责，更不应该责备他人。要不然，一旦陷入自责和指责的漩涡，很多有建设意义的事情，我们可能没有意识去做；或者即使意识到了，也没法做，做不好。

我这么说，你是不是开始有疑惑了：人人都会犯错误，还重复犯，还不能批评，这怎么能编写出优秀的代码呢？换句话说就是，我们怎么样才会少犯错误呢？

把错误关在笼子里

人人都会犯错误，苹果的工程师也不例外。所以，“GoTo Fail”的“幼稚”漏洞，实在是在情理之中。可是，这样的漏洞是如何逃脱重重“监管”，出现在最终的发布产品中，这多多少少让我有点出乎意料。

我们先来看看，这个错误是经过了怎样的“工序”，穿越了多少障碍，需要多少运气，最终才能被“发布”出来。

我把这样的工序总结为“五道关卡”。

第一道关：程序员

提高程序员的修养，是一个永不过时的课题。从别人的失败和自己的失败中学习、积累、提高，是一个程序员成长的必修课。我知道，这是你和我一直都在努力做的事情。

第三行的“GoTo Fail”，实在算得上“漏网之鱼”，才可以逃过哪怕最平凡的程序员的眼睛，堂而皇之地占据了宝贵的一行代码，并且狠狠地玩耍了一把。

现在我们可以再回过来看看那段错误代码，如果是你写，你会怎么写呢？从你的角度来看，又有哪些细节可以帮助你避免类似的错误呢？这两个问题，你可以先停下来 1 分钟，想一想。

在我看来，上面那段代码，起码有两个地方可以优化。如果那位程序员能够按照规范的方式写代码，那“GoTo Fail”的漏洞应该是很容易被发现。我们在遇到问题的时候，也应该尽量朝着规范以及可持续改进的角度去思考错误背后的原因，而非一味地自责。

首先，他应该正确使用缩进。你现在可以再看下我优化后的代码，是不是第三行的代码特别刺眼，是不是更容易被“逮住”？

    if ((error = doSomething()) != 0)

        goto fail;

    goto fail;

    if ((error= doMore()) != 0)

        goto fail;

fail:

    return error;

其次，他应该使用大括号。使用大括号后，这个问题是不是就自动消失了？虽然，多余的这一行依然是多余的，但已经是没有多大危害的一行代码了。

    if ((error = doSomething()) != 0) {

        goto fail;

        goto fail;

    }

    if ((error= doMore()) != 0) {

        goto fail;

    }

fail:

    return error;

从上面这个例子里，不知道你有没有体会到，好的代码风格带来的好处呢？工作中，像苹果公司的那位程序员一样的错误，你应该没少遇到吧？那现在，你是不是可以思考如何从代码风格的角度来避免类似的错误呢？

魔鬼藏于细节。很多时候， 优秀的代码源于我们对细节的热情和执着。可能，你遇到的或者想到的问题，不是每一个都有完美的答案或者解决办法。但是，如果你能够找到哪怕仅仅是一个小问题的一个小小的改进办法，都有可能会给你的代码质量带来巨大的提升和改变。

当然，你可能还会说，我代码风格不错，但是那个问题就是没看到，这也是极有可能的事情。是这样，所以也就有了第二道工序：编译器。

第二道关：编译器

编译器在代码质量方面，作为机器，恪尽职守，它可以帮助我们清除很多错误。还是以上面的漏洞代码为例子， 这一次其实编译器的防守并没有做好，因为它毫无察觉地漏过了多余的“GoTo Fail”。

在 Java 语言里，对于无法访问的代码（第三行后的代码）， Java 编译器就会及时报告错误。而在 2014 年 2 月的 GCC 编译器里，并没有提供这样的功能。

至今，GCC 社区对于无法访问代码的检查，还没有统一的意见 。然而，GCC 社区并没有完全浪费这个“GoTo Fail”的问题 。为解决类似问题，从 GCC 6 开始，GCC 社区为正确使用缩进提供了一个警告选项（ -Wmisleading-indentation ）。如果代码缩进格式没有正确使用，GCC 就会提供编译时警告。现在，如果我们启用并且注意到了 GCC 编译器的警告，犯类似错误的机会应该会大幅度地降低了。

在这里，我要提醒你的是，对于编译器的警告，我们一定要非常警觉。能消除掉所有的警告，你就应该消除掉所有的警告。就算实在没有办法消除掉编译警告，那你也一定要搞清楚警告产生的原因，并确认编译警告不会产生任何后续问题。

第三道关：回归测试 （Regression Testing）

一般地，软件测试会尽可能地覆盖关键逻辑和负面清单，以确保关键功能能够正确执行，关键错误能够有效处理。一般情况下，无论是开发人员，还是测试人员，都要写很多测试代码，来测试软件是否达到预期的要求。

另外，这些测试代码还有一个关键用途就是做回归测试 。如果有代码变更，我们可以用回归测试来检查这样的代码变更有没有使代码变得更坏。

上述的“GoTo Fail”这样的代码变更，涉及到一个非常重要的负面检查点。遗憾的是，该检查点并没有包含在回归测试中；或者，在这个变更交付工程中，回归测试并没有被执行。

软件测试没有办法覆盖所有的使用场景。但是，我们千万要覆盖关键逻辑和负面清单。一个没有良好回归测试的软件，很难保证代码变更的质量；也会使得代码变更充满不确定性，从而大幅地提高代码维护的成本。

第四道关：代码评审 （Code Review）

代码评审是一个有效的在软件研发过程中抵御人类缺陷的制度。通过更多的眼睛检查软件代码，被忽视的错误更容易被逮住，更好的设计和实现更容易浮现出来。

那代码评审是怎么实现的呢？一般情况下，代码评审是通过阅读代码变更进行的。而代码变更一般通过某种形式的工具呈现出来。比如 OpenJDK 采用的Webrev 。你可以访问我的一个代码评审使用的代码变更页面 ，感受下这种呈现方式。

回到上面那个“GoTo Fail”的代码变更，看起来什么样子呢？下面是其中的一个代码变更版本示例：

if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)

    goto fail;

+   goto fail;

if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)

    goto fail;

添加的这行代码，还是相当刺眼的。多一些眼睛盯着这些代码，多一些形式展现这些变更，就会大幅度地降低问题藏匿的几率。

上述的“GoTo Fail”这样的代码变更，怎么就逃过代码评审者的眼睛呢？我想说的是，评审者也是人，我们不能期望评审者能发现所有的问题。

第五道关：代码分析 （Code Analysis）

静态代码分析（Static Code Analysis）是通过对源代码的检查来发现潜在问题的一种软件质量保障方式。有很多静态代码分析工具可以帮助你检查代码缺陷，比如说商业软件 Coverity，以及开源软件 FindBugs。你可以试试看，有哪些工具可以检测到这个“GoTo Fail”问题。

代码覆盖率（Code Coverage）是一个反映测试覆盖程度的指标。它不仅仅量化测试的指标，也是一个检测代码缺陷的好工具。如果你的代码覆盖率测试实现了行覆盖（Line Coverage），这个“GoTo Fail”问题也很难闯过这一关。

很显然，苹果的这一关也没有拦截住“GoTo Fail”。这样，“GoTo Fail”就像千里走单骑的关云长，闯过了五关（有些软件开发流程，也许会设置更多的关卡）。

代码制造的流水线

我们分析了这重重关卡，我特别想传递的一个想法就是，编写优秀的代码，不能仅仅依靠一个人的战斗。代码的优秀级别，依赖于每个关卡的优秀级别。高质量的代码，依赖于高质量的流水线。每道关卡都应该给程序员提供积极的反馈。这些反馈，在保障代码质量的同时，也能帮助程序员快速学习和成长。

可是，即使我们设置了重重关卡，“GoTo Fail”依然“过关斩将”，一行代码一路恣意玩耍。这里面有关卡虚设的因素，也有我们粗心大意的因素。我们怎么样才能打造更好的关卡，或者设置更好的笼子？尤其是，身为程序员，如何守好第一关？

欢迎你在留言区说说自己的思考。下一讲，我们再接着聊这个话题。

一起来动手

下面的这段代码，有很多疏漏的地方。你看看自己读代码能发现多少问题？上面我们讨论的流程能够发现多少问题。不妨把讨论区看作代码评审区，看看在讨论区都有什么不同的发现。

package com.example;

import java.util.Collections;

import java.util.List;

import javax.net.ssl.SNIServerName;

class ServerNameSpec {

    final List serverNames;

    ServerNameSpec(List serverNames) {

        this.serverNames = Collections.unmodifiableList(serverNames);

    }

    public void addServerName(SNIServerName serverName) {

        serverNames.add(serverName);

    }

    public String toString() {

        if (serverNames == null || serverNames.isEmpty())

        return "<no server name indicator specified>";

        StringBuilder builder = new StringBuilder(512);

        for (SNIServerName sn : serverNames) {

            builder.append(sn.toString());

            builder.append("\n");

        }

        return builder.toString();

    }

}

你也可以把这篇文章分享给你的朋友或者同事，一起来讨论一下这道小小的练习题。

03 | 优秀程序员的六个关键特质

上一讲我们从“五道关卡”的角度讨论了如何写出优秀代码这个话题。对我们每个程序员个体来说，其实第一道“程序员”关卡最重要。没有优秀的程序员，就没有优秀的代码。那么，什么样的程序员才是优秀的程序员呢？

如果去问一个程序员，得到的答案有可能是，“写的一手好代码，做过几个大项目，设计、编程样样不在话下”。如果去问程序员的同学，得到的答案有可能是，“刚毕业就去了大厂，中秋节发的月饼羡慕死个人” 。如果去问程序员的同事，得到的答案有可能是，“人挺好，干活挺快的” 。如果去问 HR，得到的答案有可能是“省钱、出活”。

你看，这些答案都没什么毛病，各有各的道理。你的答案是怎样的呢？我自己工作中见过特别多优秀的程序员，从他们身上发现了不少共性的特质，我总结为以下六项，下面一一给你介绍。

掌握一门编程语言

第一点很重要，优秀的程序员需要写的一手好代码，再简单来说，最起码需要能够熟练操控一门编程语言。

我们都会使用筷子，吃饭的时候，我们不需要有意识地控制着筷子的力度、开合和角度，也能准确地使用它。这个使用筷子的效率，是我们小时候长期练习的结果。每个人拿筷子的方法可能会有些差异，但是都不影响我们现在精准地、高效地使用筷子。

编写程序也是这样。熟练了之后，很多语法、语句在我们编写程序的时候，会下意识地就流露出来。如果我们设计一个线程类，下面的代码里，我觉得至少 class 和 extends 这两个关键字的使用是不需要大脑有意识地参与的。

public class MyThread extends Thread {

  ...

}

如果把编程语言看成一套功法，比如降龙十八掌，这套功法练得越多，练得越纯熟，用起来越得心应手。武侠小说里，一套功法只有练了全套，才有最大的威力。对于编程语言，我们了解得越多，熟知的招式就越多，可选择的范围就越大，我们就有更多的活动空间和解决问题的办法。

编程语言，基本上是相通的。掌握了第一门编程语言后，第二门语言学起来就快很多，第三门语言学起来更快。现在我们几乎都是多语言使用者，但一定要先精通一门语言，达到像用筷子那样的熟练程度。

解决现实的问题

掌握了一门编程语言，然后呢？代码是要解决具体的问题的，我们需要通过编程语言把解决问题的办法和思路表达出来。

要解决具体的问题，仅仅做到熟练使用编程语言是远远不够的，我们还需要更多工具。如果做前端，需要理解 HTML 和浏览器；如果做后端，需要掌握数据库和操作系统；如果做云计算，需要掌握 Kubernetes 等等。就像学了分筋错骨手，还要学降龙十八掌；学了七十二路空明拳，还要学左右互搏。俗话说，艺多不压身，工具箱永远都不嫌满。

有了工具还不够，优秀的程序员还要深入理解问题，懂得问题的最核心价值。只有理解了问题，看到了解决问题的价值，我们才能够真正解决好问题，并且从中获得满满的成就感。我们一定要记得，程序员的存在不是为了写代码，而是为了解决现实问题，实现现实价值。

真实的作品，都带着我们对于现实问题的理解。而打磨一个这样的作品，需要缜密的逻辑、突破创新和贯彻执行。通过使用合适的工具，把简单的、一行一行的代码，耐心地粘合、打磨成优秀的作品。

如果说花样的工具是外家功夫，思维能力和行为能力可以算是内功。

优秀的程序员，是一个内外双修的程序员。如果一个程序员可以熟练使用工具，有清晰的解决问题思路，能明晰地传达产品价值，那么他编写代码就不存在什么巨大的困难了。

发现关键的问题

有了工具，遇到问题能解决掉，我们就可以做事情了。优秀的程序员还有一项好本领，就是发现关键的问题。能够发现关键的问题，我觉得是一个好程序员和优秀程序员的分水岭。

优秀的程序员，能够发现一门编程语言的缺陷，一个顺手工具的局限。所以，他知道该怎么选择最合适的工具，该怎么避免不必要的麻烦。

优秀的程序员，能够发现解决方案背后的妥协和风险。所以，他可以预设风险防范措施，设置软件的适用边界。

优秀的程序员，能够敏锐地观察到产品的关键问题，或者客户未被满足的需求。所以，他可以推动产品持续地进步和演化。

能够发现关键的问题，意味着我们可以从一个被动的做事情的程序员，升级为一个主动找事情的程序员。

能够发现关键的问题，往往需要我们对一个领域有很深入的研究和深厚的积累，并且对新鲜事物保持充分的好奇心和求知欲。

掌握一门编程语言，解决现实的问题，能发现关键的问题，做到这三点，你就已经是一名优秀的程序员了。如果说优秀程序员有一个评价标准的话，这三条一定是硬性指标，接下来再介绍三条软性指标。

沉静的前行者

首先，优秀的程序员，一定是懂得妥协，懂得选择，一步一步把事情沉静地朝前推动的人。

如果真的较起真来，每一行代码，就像孔乙己的茴香豆，都有不止四样的写法。可是，最终的程序，只能选择唯一的一种。优秀的程序员都有在不断平衡、不断妥协中推动事物前行的能力和修为。

如果一个人说要一个完美的代码、完美的算法，完美的程序、完美的产品，我立刻就会非常紧张。完美是不存在的，所以我们才追求完美。对完美的过分追求，可能是一个代价高昂，收获甚小的行为。很多时候，我们不需要完美的东西。如果我只是想看看泰山山顶的日出，你就不要问我是爬上去的还是乘索道上去的了。

对完美的理解，也是千差万别的。如果你的完美和我的完美发生碰撞，一定有一方需要妥协，我们才可以共同迈出下一步。

而且，完美也可能意味着不承认缺陷，不承认未知。这样，我们可能在心理上就不会对代码的未知风险做出充分的预判，留出足够的安全缓冲空间。

我们写的每一行代码，都可能存在问题。有时候，我发现别人的代码的问题；有时候，别人发现我的代码的问题。我们最后都会明白，要坦诚地面对别人的问题，也要坦然地面对自己的问题。在解决问题和帮助别人解决问题中，我们把一个产品变得越来越好，问题越来越少。

可以依赖的伙伴

其次，优秀的程序员是他人可以依赖的伙伴。

如果我们把软件开发看成一个循环的流水线，参与其中的每个人，都要接受来自上一级的输入内容，在当前环节和同事合作，创造面向下一级的输出内容。优秀的程序员，知道团队合作的重要性，是一个优秀的团队成员。他在团队中能够快速学习、成长，变得越来越优秀，也能够帮助其他团队成员变得越来越优秀。

优秀的程序员是一个领导型的人。他能够倾听，持续地获取他人的优秀想法，以及不同的意见。他能够表达，准确地传递自己的想法，恰当地陈述自己的意见。他是一个给予者，给别人尊重，给别人启发，给别人指导，给别人施展才华的空间。他是一个索取者，需要获得尊重，需要获得支持，需要持续学习，需要一个自主决策的空间。他能够应对压力，承担责任，积极主动，大部分时候保持克制和冷静，偶尔也会表达愤怒。他具有一定的影响力，以及良好的人际关系，能够和各种类型的人相处，能够引发反对意见，但是又不损害人际关系。他知道什么时候可以妥协，什么时候应该坚持。

上面的这些，通常称为“软技能”。如果说，编程语言、花样工具、逻辑思维、解决问题这些“硬技能”可以决定我们的起点的话，影响力、人际关系这些“软技能”通常影响着我们可以到达的高度。因为，无论我们是加入他人的团队，或者组建自己的团队，我们只有在团队中才能变得越来越出色，做的事情越来越重要。所以，我们需要成为优秀的团队成员，接受影响，也影响他人。

时间管理者

最后我想和你分享的一点是，优秀的程序员是高效的时间管理者。

时间总是我们最大的障碍，优秀的程序员更是如此。没完没了的会议，没完没了的讨论，没完没了的学习，没完没了的需求，没完没了的 bug，时间拦住了我们的雄心壮志和大好宏图。

时间面前，人人平等，没有人一天的时间比别人多一秒。优秀的程序员会更好地管理时间，或者提高效率，或者用好时间。

你有没有听说过这样的故事？ 一家工厂的发动机坏了，请了很多人都没有修好。无奈，请了一位工程师，他听了听声音，在发动机上画了一道线，说：“打开，把线圈拆了”。果然，发动机就修好了。不管这个小故事是真的也好，假的也好，类似的事情在软件公司时时刻刻都在发生。有经验的程序员三分钟就能发现的问题，外行可能需要折腾好几天。持续地提高我们的硬技能和软技能，可以让我们做事情更快更好。

坚持把时间用在对的地方，用在价值更大的地方。事情总是做不完的。一般的工程师，都有一种打破砂锅问到底的精气神，这是好事。可是，这顺便带来了一点点的副作用，很多人有一点点小小的强迫症，很多事情，喜欢自己动手整个清楚明白。可是，事情又特别多，很多事情根本就顾不上。怎么办呢？

要做只有你才能做的事情。是的，有很多事情，只有你可以做，只有你做得最快最好。其他的同事也是一样的，有很多事情，只有他们能做，只有他们做得最快最好。选择最合适的人做最合适的事，这不仅是领导的工作分配，也可以是我们自己的协商选择。

事情做不完，就需要面临选择。要坚持做需要做的事情。不需要的、不紧急的、价值不大的，我们可以暂时搁置起来。一个人，能做的事情是有限的，能把最重要的事情最好，就已经很了不起了。

学会选择，是我们进阶道路上的一个必修课。

总结

最后，总结一下，优秀的程序员是什么样的？ 优秀的程序员可以熟练地使用必要的工具，发现和解决复杂的现实问题；优秀的程序员可以在一个团队里，高效沉静地把项目和团队一步一步地朝前推进。

现在，把我们今天讲的优秀程序员六大特质当作一套自测题，和自己对照一下，自己在哪些方面做得比较好？在哪些方面还需要继续精进呢？

欢迎你留言聊聊自己的经验，如果有什么问题或困惑，也可以提出来我们一起讨论。

如果今天的内容对你有帮助，也请你分享给身边的朋友，和他一起精进。

04 | 代码规范的价值：复盘苹果公司的GoToFail漏洞

我们在上一讲中讨论了一个优秀的程序员都需要具备哪些良好的品质，第一点就是要熟练掌握一门编程语言。

作为每天都要和代码打交道的人，光是熟练掌握还不够。我们需要像文字写作者一样，对代码有一种“洁癖”，那就是强调代码的规范化。

什么是编码规范？

要回答为什么需要编码规范，我们首先要了解编码规范指的是什么。

编码规范指的是针对特定编程语言约定的一系列规则，通常包括文件组织、缩进、注释、声明、语句、空格、命名约定、编程实践、编程原则和最佳实践等。

一般而言，一份高质量的编码规范，是严格的、清晰的、简单的，也是权威的。但是我们有时候并不想从内心信服，更别提自觉遵守了。你可能想问，遵循这样的约定到底有什么用呢？

编码规范可以帮我们选择编码风格、确定编码方法，以便更好地进行编码实践。 简单地说，一旦学会了编码规范，并且严格地遵守它们，可以让我们的工作更简单，更轻松，少犯错误。

这个问题弄明白了，我们就能愉快地遵守这些约定，改进我们的编程方式了。

规范的代码，可以降低代码出错的几率

复杂是代码质量的敌人。 越复杂的代码，越容易出现问题，并且由于复杂性，我们很难发现这些隐藏的问题。

我们在前面已经讨论过苹果公司的安全漏洞（GoToFail 漏洞），接下来再来看看这个 bug 的伪代码。这个代码很简单，就是两个 if 条件语句，如果判断没问题，就执行相关的操作。

    if ((error = doSomething()) != 0)

        goto fail;

        goto fail;

    if ((error= doMore()) != 0)

        goto fail;

fail:

    return error;

这段代码没有正确地使用缩进和大括号，不是一段符合编码规范的源代码。 如果我们使用符合规范的编码方式，这个安全漏洞就自然消失了。你可以看到，下面的代码里，我给 if 语句中加了大括号，代码看起来一下子就简单很多了。

    if ((error = doSomething()) != 0) {

        goto fail;

        goto fail;

    }

    if ((error= doMore()) != 0) {

        goto fail;

    }

fail:

    return error;

所以在编码的时候，我们应该尽量使代码风格直观、逻辑简单、表述直接。 如果遵守编码规范，我们就可以更清楚、直接地表述代码逻辑。

规范的代码，可以提高编码的效率

还记得我们在前面讨论过代码“出道”的重重关卡吗？这些关卡，构成了代码制造的流水线。优秀的代码，来源于优秀的流水线。

如果我们都遵守相同的编码规范，在每一道关卡上，会产生什么样的质变呢？

在程序员编写代码这道关，如果我们规范使用缩进、命名、写注释，可以节省我们大量的时间。比如，如果使用规范的命名，那么看到名字我们就能知道它是一个变量，还是一个常量；是一个方法，还是一个类。

在编译器这道关，我们可以避免额外的警告检查，从而节省时间。还记得我们前面讨论过的 GCC 关于正确使用缩进的编译警告吗？ 如果有编译警告出现，我们一般都要非常慎重地检查核对该警告有没有潜在威胁。这对我们的精力和时间，其实是不必要的浪费。

还记得 GCC 由于老旧的编程风格的原因，不支持无法访问代码编译错误吗？ 过度自由的编码风格，有时候甚至会阻碍编译器开发一些非常有用的特性，使得我们无心的过失行为越积累越不好解决。

在代码评审这道关，如果我们不遵守共同的编码规范，这多多少少会影响评审者阅读代码的效率。为什么呢？因为评审者和编码者往往有着不一样的审美偏好。一条评审意见，可能要花费评审者很长时间来确认、评论。 然后，源代码编写者需要分析评审意见，再回到流水线的第一关，更改代码、编译、测试，再次提交评审，等待评审结果。

审美偏好一般都难以协调，由此导致的重复工作让编码的效率变得更低了。

在代码分析这道关，编码规范也是可以执行检查分析的一个重要部分。类似于编译器，如果有警告出现，分析警告对我们的精力是一种不必要的浪费； 如果过度自由，同样会阻碍代码分析工具提供更丰富的特性。

只要警报拉响，不管处在哪一个关卡，源代码编写者都需要回到流水线的第一关，重新评估反馈、更改代码、编译代码、提交评审、等待评审结果等等。每一次的返工，都是对时间和精力的消耗。

总结一下，在代码制造的每一道关卡，规范执行得越早，问题解决得越早，整个流水线的效率也就越高。

前一段时间，阿里巴巴发表了《阿里巴巴 Java 开发手册》。我相信，或许很快，执行阿里巴巴 Java 编码规约检查的工具就会出现，并且成为流水线的一部分。 对于违反强制规约的，报以错误；对于违反推荐或者规约参考的，报以警告。这样，流水线才会自动促进程序员的学习和成长，修正不符合规范的编码。

规范的代码，降低软件维护成本

代码经过重重关卡，好不容易“出道”了，这样就结束了吗？

恰恰相反，“出道”之后，才是代码生命周期的真正开始。

如果是开源代码，它会面临更多眼光的挑剔。即使是封闭代码，也有可能接受各种各样的考验。"出道”的代码有它自己的旅程，有时候超越我们的控制和想象。在它的旅程中，会有新的程序员加入进来，观察它，分析它，改造它，甚至毁灭它。软件的维护，是这个旅程中最值得考虑的部分。

有统计数据表明，在一个软件生命周期里，软件维护阶段花费了大约 80% 的成本。这个成分，当然包括你我投入到软件维护阶段的时间和精力。

举例来说吧，让我们一起来看看，一个 Java 的代码问题，在 OpenJDK 社区会发生什么呢？

在 Java 的开发过程中，当需要代码变更时，我们需要考虑一个问题：使用这些代码的应用是否可以像以前一样工作？

一旦出现了问题，一般有两种可能：要么是 Java 的代码变更存在兼容性问题，要么存在应用使用 Java 规范不当的问题。这就需要确认问题的根源到底是什么。

由于 OpenJDK 是开源代码，应用程序的开发者往往需要调试、阅读源代码。阅读源代码这件事情，在一定程度上，类似于代码评审的部分工作。如果代码是规范的，那么他们的阅读心情就会好一些，效率也就更高。

如果发现了任何问题，可以提交问题报告。问题报告一般需要明确列出存在的具体问题。 对于问题报告，也会有专门的审阅者进行研究分析，这个问题描述是否清晰？它是不是一个真正的问题？由谁解决最合适？

很多情况下，报告的审阅者也需要阅读、调试源代码。良好的编码规范，可以帮助他们快速理解问题，并且找到最合适的处理人员。

如果确定了问题，开发人员或者维护人员会进一步评估、设计潜在的解决方案。如果原代码的作者不能提供任何帮助，比如已经离职，那么他们可以依靠的信息，就只有代码本身了。

你看，这个代码问题修改的过程重包含了很多角色：代码的编写者、代码的使用者、问题的审阅者以及问题的解决者， 这些角色一般不是同一个人。在修改代码时，不管我们是其中的哪一个角色，遵守规范的代码都能够节省我们的时间。

很多软件代码，其生命的旅程超越了它的创造者，超越了团队的界限，超越了组织的界限，甚至会进入我们难以预想的领域。即使像空格缩进这样的小问题，随着这段代码的扩散，以及接触到这段代码人数的增加，由它造成的效率问题也会对应的扩散、扩大。

而严格遵守共同的编码规范，提高代码的可读性，可以使参与其中的人更容易地理解代码，更快速地理解代码，更快速地解决问题。

编码规范越使用越高效

除了上面我们说道的好处，编码规范还有一个特点，就是越使用越高效。

比如我们小时候都背诵过乘法口诀，如果我问你，3 乘 3 得几？ 我相信，你立即就会告诉我，答案是 9。 不管这时候你是在开车、还是在走路；是在吃饭，还是在玩游戏。

如果我问你，13 乘以 23，结果是多少？ 除非你经过非常特殊的训练，你不会立即就有答案，甚至你走路的时候，不停下脚步，就算不出这个结果。

如果我问一个还没学过乘法的小孩子呢？ 3 乘 3 的算术，对于小孩子，也许是一个不小的难题。

对于背诵过乘法口诀的我们来说，3 乘 3 的算术根本就不需要计算，我们的大脑可以快速地、毫不费力地、无意识地处理这样的问题。 这种系统是我们思维的快系统。 快系统勤快、省力，我们喜欢使用它。

而对于 13 乘以 23 的算术，我们的大脑需要耗费脑力，只有集中注意力，才能运算出来。这种系统是我们思维的慢系统。慢系统懒惰、费劲，我们不愿意使用它。

快系统和慢系统分工写作，快系统搞不定的事情，就需要慢系统接管。 快系统处理简单、固定的模式，而慢系统出面解决异常状况和复杂问题。

比如上面苹果公司安全漏洞的那个例子，如果我们像乘法表一样熟练使用编码规范，一旦遇到没有使用大括号的语句，我们立即就会非常警觉。 因为，不使用大括号的编码方式不符合我们习以为常的惯例，快系统立即就能判别出异常状况，然后交给慢系统做进一步的思考。 如果我们没有养成编码规范的习惯，我们的快系统就会无视这样的状况，错失挽救的机会。

所以，我们要尽早地使用编码规范，尽快地培养对代码风格的敏感度。 良好的习惯越早形成，我们的生活越轻松。

小结

对于编码规范这件事，我特别想和你分享盐野七生在《罗马人的故事》这套书里的一句话：“一件东西，无论其实用性多强，终究比不上让人心情愉悦更为实用。”

严格地遵守编码规范，可以使我们的工作更简单，更轻松，更愉快。 记住，优秀的代码不光是给自己看的，也是给别人看的，而且首先是给别人看的。

你有什么编码规范的故事和大家分享吗？ 欢迎你在留言区写写自己的想法，我们可以进一步讨论。也欢迎你把今天的文章分享给跟你协作的同学，看看编码规范能不能让你们之间的合作更轻松愉快。

一起来动手

下面的这段代码，我们前面用过一次，我稍微做了点修改。我们这次重点来看编码的规范，有哪些地方你看着不顺眼，你会怎么改进？

package com.example;

import java.util.Collections;

import java.util.List;

import javax.net.ssl.SNIServerName;

class ServerNameSpec {

    final List<SNIServerName> serverNames;

    ServerNameSpec(List<SNIServerName> serverNames) {

        this.serverNames = Collections.<SNIServerName>unmodifiableList(serverNames);

    }

    public String toString() {

        if (serverNames == null || serverNames.isEmpty())

        return "<no server name indicator specified>";

        StringBuilder builder = new StringBuilder(512);

        serverNames.stream().map((sn) -> {

            builder.append(sn.toString());

            return sn;

        }).forEachOrdered((_item) -> {

            builder.append("\n");

        });

        return builder.toString();

    }

}

你也可以把这篇文章分享给你的朋友或者同事，一起来讨论一下这道小小的练习题。

05 | 经验总结：如何给你的代码起好名字？

上一节我们讲了编码规范的重要性，而编码规范，从起一个好名字开始。但起名字，也没有我们想得那么简单。有个流传很广的戏言：“计算机科学只有两件难事，废弃缓存和取名字。”

之所以说是戏言，因为取名字这件事无论如何都不算是高深的学问；之所以广泛流传，因为取名字真的就是一件很难的事情，而且起名字是关乎代码质量的大事。

给小孩取过名字的人都知道，取个好名字有多难，又要合八字，又要算五行，还要避尊者讳。 写程序给代码取名字更难，每天都要想很多名字。给孩子取名字，父母喜欢就行，给代码取名字，还要别人也喜欢。

为什么需要一个好名字？

名字要准确地代表它背后的东西，并且还能让代码干净漂亮。不然，我们的思路就会受到干扰，影响我们的思考和心情。

比如说，对于答案只有是与非两个选择的时候，我们通常使用布尔类型（boolean）。所以，取名字的时候，我们通常需要一个表达疑问的前缀，比如是不是“is”。

public boolean isEmpty();     // String.isEmpty()

但如果我们把这样的疑问前缀，使用到一个非布尔类型上，会有什么效果？

public byte[] isEmpty();

你是不是觉得如鲠在喉，对于代码要干什么百思不得其解？ 反正，我写这个例子的时候，感觉像是吃了五百只苍蝇！

名字就是沟通的方式，错误的命名很难让我们清楚地理解代码真实的意图。所以，混淆的命名很难让我们阅读和理解代码。

虽然编译器不关心命名的好坏，但是我们却可以从一个好名字中获得巨大的好处。

为什么需要命名规范？

虽然起一个好名字的重要性不言而喻，但命名规范的选择，以及执行程度，却是一个有争议的话题。有人喜欢这种规范，有人喜欢那种规范，有人干脆认为规范都太教条，真是众口难调。此外，即使已知且明确定义了命名规范，某些组织也无法始终如一地遵守它们，从而导致不一致和混淆。如果命名规范内部不一致，任意且难以记忆，这些挑战还会加剧。

所以使用一个好的命名规范是非常重要的，我们都能获得哪些好处呢？

为标识符提供附加的信息，赋予标识符现实意义。帮助我们理顺编码的逻辑，减少阅读和理解代码的工作量；

使代码审核变得更有效率，专注于更重要的问题，而不是争论语法和命名规范这类小细节，提高开发效率；

提高代码的清晰度、可读性以及美观程度；

避免不同产品之间的命名冲突。

有哪些常见的命名方法？

尽管不同的编程环境、不同编程语言也需要沟通，但遗憾的是，到目前为止，还没有一种通用的命名方法。 在不同的场景下，程序员们有着不同的偏好。我们需要阅读很多代码，多了解一些命名方法，这样我们才能更好地理解不同风格的代码。

我来一一介绍下几种常见的命名方法。

1. 驼峰命名法（CamelCase）

驼峰命名法指的是使用大小写混合的格式，单词之间不使用空格隔开或者连接字符连接的命名方式。它有两种格式：大驼峰命名法（UpperCamelCase）和小驼峰命名法（lowerCamelCase）。

大驼峰命名法的第一个单词以大写字母开始，其余的和小驼峰命名法相同。 比如：LastName, InputStream。

小驼峰命名法的第一个单词以小写字母开始，其他单词以大写字母开始，其余字母使用小写字母。 比如：firstName, toString。

有时候，一个名字可能有不只一种合理形式，比如缩略语（IPv6）或者异常的结构（iOS)。 为了减少这种不确定性，Google 定义了以下的转换规则：

从正常的表达形式开始，把短语转换成 ASCII 码，并且移除单引号。 例如，“Müller’s algorithm”转换为“Muellers algorithm”；

如果上述结果含有其他标点符号，比如连字符，在该符号处，把这个结果切分成单词形式。 如果某个单词已经是驼峰形式，也相应地切分开来。 例如，“AdWords”切分成“ad words”，“non-current assets”切分成“non current assets”；

将所有字母转换为小写字母，然后将每个单词的首字母大写，这样就得到了大驼峰式命名的形式； 如果第一个单词的首字母小写，就得到了小驼峰式命名的形式；

将所有的单词连在一起，就是最后的标识符命名。

下面的表格列出了不同例子的正确转换形式，和容易出错的转换形式 （出自“Google Java Style Guide”）。

2. 蛇形命名法（snake_case）

在蛇形命名法中，单词之间通过下划线“_”连接，比如“out_of_range”。

3. 串式命名法（kebab-case）

在蛇形命名法中，单词之间通过连字符“-”连接，比如“background-color”。

4. 匈牙利命名法

在匈牙利命名法中，标识符由一个或者多个小写字母开始，这些字母用来标识标识符的类型或者用途。标识符的剩余部分，可以采取其他形式的命名法，比如大驼峰命名法。

如果起始的小字母用来表示标识符的数据类型，这种命名法也被称为系统匈牙利命名法。 比如：

lAccountNum 标识一个 _ 长整数 _（首字母“l”，long）。

szName 标识一个 _ 零字符结束的字符串 _（首字母“sz”，zero-terminated string）。

如果起始的小字母用来表示标识符的实际用途，这种命名法也被称为应用匈牙利命名法。 比如：

rwPosition 标识一个 _ 行 _（首字母“rw”，row）。

usName 标识一个 _ 非安全字符串 _（首字母“us”, unsafe string）。

由于在微软产品中的广泛使用，匈牙利命名法曾经是一种流行的命名形式。然而，由于这种命名会带来不必要的记忆负担和阅读障碍，导致命名规则的执行和名称的维护都很困难，微软已经抛弃了这种命名形式。

由于历史的原因，还有很多代码使用这种命名形式。阅读这些代码时，你可以选择性地忽略这些表示类型或者用途的字母前缀。

Java 命名规范

一段代码，是不是只能使用一种命名方法？ 一般来说，一个编码规范会组合使用这些命名方法，每一种命名方法都被规定了适用的范围。 这样就形成了命名规范。

比如，Java 的命名规范可以使用下表来表示。

需要注意的是，常量必须是真的不能改变的量，不打算改变或者能够改变的量都不能算作常量。

比如，下面的例子声明的是常量：

static final short MAX_VALUE = 32767;

static final Set<String> EMPTY_NAMES =

        Collections.unmodifiableSet(Collections.emptySet());

下面的例子声明的就不是常量，它们的值都可以改变：

static short nonFinalShort = 32767;

static final Set<String> mutableNames = Collections.emptySet();

static final String[] names = { "Alice", "Bob", "Tom" };

需要注意的是，方法标识符使用动词或者动词短语，这是传统的方法命名。如果能够分隔开配置（set）和使用（get），使用名词的方法标识符。比如 Builder 模式的接口设计。这个接口设计和命名惯例，我们以后再讨论。

怎么取好名字？

了解了命名方法后，你是不是想知道怎么取好名字呢？一般来说，给代码取名字，需要遵守如下三条原则。

1. 要有准确的意义

名字要能够准确、完整地表达出它代表的意义，可以见字知意，名副其实。

比如，表达式“a = b - c”的语法是没有什么问题，可是该表达式代表的实际含义并不清楚。相比而言，“grossIncome = grossRevene - costOfGoodsSold”就有很准确、清晰的现实意义。这样的命名更容易阅读和理解。

2. 严格遵守命名规范

不同的编程环境，偏爱不同的命名规范，比如 Java 倾向于使用驼峰命名法，C 语言倾向于使用蛇形命名法，CSS 使用串式命名法。 尽管如此，如果定义了个性化的命名规范，请严格遵守自定义的命名规范，如果没有定义个性化的命名规范，我们就需要严格遵守业界普遍公认的命名规范。

3. 可读性优先

名字的可读性一定要优先考虑，一般需要注意以下几点。

可读性强的名字优先于简短的名字，尽量使用完整的词汇。

不要使用缩写、简写、缩略词，除非这些词语被广泛使用。

不要使用太短的名字，比如一个字母，除非是广泛接受的特例（i/j/k/m/n 表示临时使用的整数，c/d/e 表示临时使用的字符）。

避免含糊、混淆或者误导。

另外，不要混合使用英文和汉语拼音。由于很多类库使用的是英文，如果使用汉语拼音命名，会造成事实上的拼音名字与英文名字的混用，所以也要尽量避免使用拼音命名。

小结

简言之，取名字要做到“信、达、雅”（准确、直观、优美）。“信”和“达”是基本要求，有才气的你可以有“雅”的追求。

取好名字是编写优秀代码最基础也是最重要的一项修炼。 你不妨试试上述的原则和规范，将它们用于新代码，或者整理老代码。 仅仅因为名字的优化，你就会立刻感受到代码质量的大幅度提升！

一起来动手

所以为了让你更好地实践，我找了一段 Java 代码。你来试试，这段代码中有哪些名字可以优化？ 欢迎你把优化的代码发在评论里，我们亲自感受下如何优化代码名字。

import java.util.HashMap;

import java.util.Map;

class Solution {

    /**

     * Given an array of integers, return indices of the two numbers

     * such that they add up to a specific target.

     */

    public int[] twoSum(int[] nums, int target) {

        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {

            int complement = target - nums[i];

            if (map.containsKey(complement)) {

                return new int[] { map.get(complement), i };

            }

            map.put(nums[i], i);

        }

        throw new IllegalArgumentException("No two sum solution");

    }

}

备注：代码选自https://leetcode.com/problems/two-sum/

你也可以把这篇文章分享给你的朋友或者同事，一起来讨论一下这道小小的练习题。

06 | 代码整理的关键逻辑和最佳案例

写一篇文章，要层次清楚、段落分明，写代码也是这样。杂志排版，要布局合理，阅读舒适，代码的编排也要这样。

可是很多人想的是，代码能工作就行，不用讲究格式；时间紧，哪有时间整理代码。

可事实上，干净整洁的代码带给我们的，远远不止格式上的赏心悦目，它更可以减少错误，提高我们的编程速度与效率。因为代码的层次结构、格式部署，是我们对自己思维的整理，也是我们思考逻辑的展现。

很多人不愿意整理代码，觉得这项工作很麻烦。其实代码的整理并不复杂，也不需要什么高深的技巧。有效地使用空行、空格和缩进，简单的分割与对齐，就可以使得代码的视觉效果甚至质量发生巨大的变化，理顺编码的思路，提高编码的效率。这是一个付出少、收获大，值得养成的好习惯。

给代码分块

其实整理代码，就是给代码分块。我们的大脑接收信息是按照分块的模式进行的。一块就是一个可识别的信息，它被安放到短期记忆的“插槽”里。信息块的实际信息量是可变的，但是越简单、越具体的信息块越容易被记住和理解。

一份好的报纸或者杂志，文章本身往往很简短，或者被分割为简短的块。使用小段落、子标题，大量留白，版面切割，尽一切可能分解文章、版面，让眼睛舒适，让大脑有时间停留下来接受每个信息快。

一个代码文件，可以看做是一个杂志版面。在这个版面里，我们要把代码整理成清晰的模块，这样每一小块在视觉上和逻辑上都是可以区分的，可以帮助我们阅读和记忆，减轻眼睛和大脑的疲劳。

比较下面的两种形式的语句。这两种形式，在实际逻辑上是完全相同的。但是，第二个语句使用了更细化的块，这样视觉形式和逻辑形式都是清晰直观的。

if (firstName != null && lastName != null)

if ((firstName != null) && (lastName != null))

如果一段代码放眼望去都是大块大块的，那对于一个初学者来说，阅读和理解就非常困难。他需要将复杂的表达式再次分解，分解到可以单独理解的变量和运算符，再重新组合。

你看，这样是不是很麻烦？

一个好的程序员，要识别并且区分代码块，让它们易于阅读和辨认。程序代码分块时，我们需要注意以下三个点。

保持代码块的单一性，一个代码块只能有一个目标。代码块内所有的内容都是为了一个目标服务的，不能把无关的内容放在同一个代码块里。同一个代码块里语句的相互联系比与相邻代码块里的语句关系更为紧密；

注意代码块的完整性。代码块是一个完整的信息块。一个代码块要表达一个相对完整的意思，不能一个意思没说完就分块了，就像话说了半句一样；

代码块数量要适当。代码块过多，会让人觉得路径太长，逻辑复杂，不容易阅读理解。一个基础的代码块最好不要超过 25 行（通常显示屏小半个页面），否则就会有增加阅读理解的困难。

我们读文章的时候，如果段落划分合理，那么读一次差不多就能清楚地记住了。日常阅读的新闻、时事、娱乐报道，和学术著作相比，通常会使用更短的段落。这就是有意缩短信息快，使报道更加适合快速阅读的需要。

同样的，我们划分代码块，就是要让人清楚地阅读和理解。如果一个代码块太长，说明我们还可以进一步细分它。

使用空白空间

给代码分块的办法之一，就是有效地使用空白空间。空白的空间虽然没有内容，但会给我们重要的信息提示。因此我们写代码的时候，一定要合理地运用空白。

为什么呢？

靠近的代码会形成一个视觉块，并且具有隐含的关联。分开的代码，意味着上下两段代码的关联没有那么紧密。这种视觉效果会给我们暗示，代码靠得越近，代码之间的关系越紧密。

因此，空白区域，不仅仅可以用来分割代码，还展示了代码之间的关系。

代码的分块，通过空格、缩进和空行来整理，或者分割。其中空格可以进行逻辑区隔，用于同一行代码内部的信息分块。缩进用于水平分割，用于表示代码块的级别。空行用于垂直分割，用于分开同级别的不同代码块。

下面这个简图，可以直观地表示这种代码块的布局方式。

或者，也可以参考下面的代码示例。 这段代码，也包含了使用空格区分同一行代码内部的信息块。

package coding;

public class CodingFormat {

    public static void main(String[] args) {

        System.out.println(

                "German say hello with "

                + Greeting.GERMAN.getGreeting());

    }

    private static enum Greeting {

        ENGLISH     ("English",  "Hello"),

        SPANISH     ("Spanish",  "Hola"),

        GERMAN      ("German",   "Hallo"),

        MANDARIN    ("Mandarin", "Ni Hao");

        private final String language;

        private final String greeting;

        private Greeting(String language, String greeting) {

            this.language = language;

            this.greeting = greeting;

        }

        private String getGreeting() {

            return greeting;

        }

        private String getLanguage() {

            return language;

        }

    }

}

那么到底如何利用空白空间呢？可以分为下面四个方法，我来一一讲解一下。

同级别代码块靠左对齐

我们阅读的习惯顺序是从左到右，代码也如此。因此不同行，但同级别的代码要靠左对齐。

比如，上面的 CodingFormat 例子中，main() 方法和 Greeting 枚举类都是 CodingFormat 的下一级内容，属于同一级别的两个块。 两个代码块的左侧要对齐。

上面的 CodingFormat 例子中的枚举常量、枚举类的变量、枚举类的方法，也是属于同一级别的内容。 对应地，左侧要对齐。

同级别代码块空行分割

我们阅读代码总是从上往下读，不同行的同级别的代码块之间，要使用空行分割。

当我们读到一个空行的时候，我们的大脑就会意识到这部分的信息结束了，可以停留下来接受这段信息。 另外，我们阅读代码的时候，碰到空白行，我们也可以暂停，往回看几行，或者重新回顾一下整个代码块，梳理逻辑、加深理解。

比如，上面的 CodingFormat 例子中，main() 方法和 Greeting 枚举类之间的空白行，getGreeting() 和 getLanguage() 方法之间的空行，都是用来分割不同的信息块的。greeting 变量和 Greeting 构造方法之间的空白行，表示变量声明结束，下面是开始定义类的方法，同样起到分割信息块的作用。

下一级代码块向右缩进

我们上面讲了同级别的代码格式，那么不同级别的呢？

区分不同行的不同级别的代码，可以使用缩进。缩进的目的是为了让我们更直观地看到缩进线，从而意识到代码之间的关系。

缩进虽然是一个共识，但怎么缩进是一个富有争议的话题。不同的语言，不同的程序员，有着不同的偏好。

我刚开始工作时，编写代码必须使用制表符缩进，并且要把制表符设置为四个字符空间。那个时候，还没有什么 IDE 可以依赖，大家都使用 vi 编辑器编写代码。缺省的，vi 制表符使用八个字符空间。所以，那个时候我们看代码的第一件事，就是看看 vi 配置有没有把制表符设置为四个字符空间。 要不然的话，代码就凌乱得找不到头绪。

现在，OpenJDK 的代码里已经不允许存在制表符了，缩进统一使用四个空格。也有的缩进规范使用两个空格，或者八个空格。

上面的 CodingFormat 例子中，我们使用了四个空格作为一个缩进单元。下面，我们看看两个空格的缩进，以及八个空格的缩进效果。

两个空格的缩进：

package coding;

public class CodingFormat {

  public static void main(String[] args) {

    System.out.println(

        "German say hello with " + Greeting.GERMAN.getGreeting());

    }

  private static enum Greeting {

    ENGLISH     ("English",  "Hello"),

    SPANISH     ("Spanish",  "Hola"),

    GERMAN      ("German",   "Hallo"),

    MANDARIN    ("Mandarin", "Ni Hao");

    private final String language;

    private final String greeting;

    private Greeting(String language, String greeting) {

      this.language = language;

      this.greeting = greeting;

    }

    private String getGreeting() {

      return greeting;

    }

    private String getLanguage() {

      return language;

    }

  }

}

两个空格的缩进，可以为每一行语句释放出更多的空间，从而减少换行，方便我们使用较长标识符或者语句。

两个空格的缩进，视觉上，缩进线靠得太近，相对而言，更容易混淆代码的分块级别。这是两个空格缩进的一个小缺点。

我们再来看下八个空格的缩进：

package coding;

public class CodingFormat {

        public static void main(String[] args) {

                System.out.println(

                        "German say hello with "

                        + Greeting.GERMAN.getGreeting());

        }

        private static enum Greeting {

                ENGLISH     ("English",  "Hello"),

                SPANISH     ("Spanish",  "Hola"),

                GERMAN      ("German",   "Hallo"),

                MANDARIN    ("Mandarin", "Ni Hao");

                private final String language;

                private final String greeting;

                private Greeting(String language, String greeting) {

                        this.language = language;

                        this.greeting = greeting;

                }

                private String getGreeting() {

                        return greeting;

                }

                private String getLanguage() {

                        return language;

                }

        }

}

八个空格的缩进，视觉上可以看到缩进线明显，缩进的级别也容易辨认。

但过多的缩进空格也有缺点。它很容易超过每行字数的限制，比如屏幕每行可容纳字符的限制。特别是当我们使用较长标识符或者语句的时候，如果每行字符空间较小（手机屏幕），就很容易导致自动换行，让代码变得难以阅读。

另外，如果我们使用八个空格作为一个缩进单元，为了代码的整洁性，我们往往会被迫使用最少的缩进嵌套，这也导致了额外的复杂性，可读性就降低了。

由于我们倾向于使用有准确意义的命名，标识符的长度往往是一个不能忽视的因素。现在的编码规范，四个空格的缩进最为常见，二个空格的缩进次之，八个空格的缩进使用的较少。

同行内代码块空格区隔

我们上面讲的都是不同行的代码该如何注意格式。位于同一行内的代码块，同样需要注意。我们可以使用空格区分开不同的逻辑单元。

比如，逗号分隔符后要使用空格，以区分开下一个信息：

String firstName, lastName;

双目运算符或者多目运算符，运算符前后都要使用空格：

firstName != null

(firstName != null) && (lastName != null)

一行一个行为

上面我们讨论了代码块的一些整理办法。 那对于每一行代码，有没有整理办法呢?

当然是有的。

一个重要的原则是，每一行代码仅仅表示一个行为。这样每一行的代码才是一个常规大小的、可以识别的基础信息块。

比如说，下面的这行代码就包含了两个行为，一个是判断行为，一个是执行行为。 两个行为放在一行，这样的代码不仅看起来有些乱，我们的大脑处理起来也有些懵。

if (variable != null) variable.doSomething();

如果分隔开这两个行为，信息块的区隔会更明显，代码会更清晰：

if (variable != null) {

    variable.doSomething();

}

一般一个完整的表达式可以看作是一个独立的行为。

编辑器的宽度，屏幕的宽度，都是有限制的。当一个完整的表达式比较长时，就需要换行。

基本的换行原则

我们前面讨论的代码分块的基本思想，同样适用于换行。基本的换行规范需要考虑以下三点。

每行代码字符数的限制。一般情况下，每行代码不要超出 80 个字符（ 80 个字符是传统终端的宽度，比如 vi 编译器）。由于屏幕尺寸和代码阅读终端的变化，现在的很多规范，开始使用 120 个字符的限制。所以我们编码的时候，需要留意一下；

如果一行不足以容纳一个表达式，就需要换行；

一般的换行原则包括以下五点。

在逗号后换行。

String variable = anObject.getSomething(longExpressionOne,

        longExpressionTwo, longExpressionThree);

在操作符前换行。

String varibale = longStringOne + longStringTwo

        + longStringThree;

高级别的换行优先。

anObject.methodOne(parameterForMethodOne,

        anObject.methodTwo(parameterForMethodTwo));

/ conventional indentation

int runningMiles = runningSpeedOne * runningTimeOne

                 + runningSpeedTwo * runningTimeTwo;

// confusing indentation

int runningMiles = runningSpeedOne

       * runningTimeOne + runningSpeedTwo

       * runningTimeTwo;

新的换行与上一行同级别表达式的开头对齐。

anObject.methodOne(parameterOne,

                   parameterTwo,

                   parameterTwo);

如果上述规则导致代码混乱或者代码太靠右，使用 8 个空格作为缩进（两个缩进单位）。

anObject.methodOne(parameterForMethodOne,

        anObject.methodTwo(parameterOneForMethodTwo,

                parameterTwoForMethodTwo,

                parameterThreeForMethodTwo));

// bad indentation

if ((conditionOne && conditionTwo)

    || (conditionThree && conditionFour)) {

    doSomething();

}

// a better indentation, using 8 spaces for the indentation

if ((conditionOne && conditionTwo)

        || (conditionThree && conditionFour)) {

    doSomething();

}

小结

今天，我给你讲了整理代码的重要性以及一些方法。其实归根结底，整理代码有一个基本的思想，那就是把代码分割成大脑能够有效识别并记忆的信息块，通过合理地使用空行、空格和缩进，把这些信息块清晰地呈现出来。清晰的代码结构，可以帮助我们理顺编码的思路，提高编码的效率，减少编码的错误，提高代码的可读性，降低代码的维护成本。

总之，整理代码带给我们的好处，是很多的。

一起来动手

还记得我们上一节的练习题吗？上次我们改名字，这次我们来修改一下代码的编排。欢迎你把优化的代码公布在讨论区，也可以写下你的优化思路，我们一起来看看编排优化后的代码是不是更好阅读了呢？

import java.util.HashMap;

import java.util.Map;

class Solution {

    /**

     * Given an array of integers, return indices of the two numbers

     * such that they add up to a specific target.

     */

    public int[] twoSum(int[] nums, int target) {

        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {

            int complement = target - nums[i];

            if (map.containsKey(complement)) {

                return new int[] { map.get(complement), i };

            }

            map.put(nums[i], i);

        }

        throw new IllegalArgumentException("No two sum solution");

    }

}

欢迎你把这篇文章分享给你的朋友或者同事，一起来探讨吧！

07 | 写好注释，真的是小菜一碟吗？

上一讲中我们讲了如何整理代码，但有些时候，即便我们取好了名字，编排好格式，但代码还是让我们抓狂，不明出处，不好理解。这时候，就需要注释登场了。

顾名思义，注释就是对代码的解释。注释不需要运行，它是用来提高代码的可读性和可维护性的。不好的注释会使代码变得更糟糕，使人更抓狂。

理想虽丰满，现实很骨感。注释虽小，写好不易。那写注释有哪些注意事项？有没有什么技巧呢？今天我们就来聊聊写注释这个话题。

当然了，不同的语言，注释的语法差别很大。为方便起见，我们统一使用 Java 语言的注释语法，来解释说明写好注释的基础原则。

注释是无奈的妥协

那你是不是有这样一个问题，源代码一定需要解释吗？

其实在理想状况下，代码不需要注释。理想的代码，命名恰当，结构清晰，逻辑顺畅，含义显而易见。但正如一个作家无法预料他的读者能否清晰地理解他的文字一样，一个程序员也不能判断他的读者能否清晰地理解他写的代码。所以，写注释其实是下巧功夫。

可是，注释也是一个麻烦鬼，可能会给我们带来三个麻烦。

首先，因为注释不需要运行，所以没有常规的办法来测试它。 注释对不对？有没有随着代码变更？这些问题都是写注释需要注意的地方。注释难以维护，这是使用注释带来的最大的麻烦。

另一个麻烦是，注释为我们提供了一个借口。使用注释来解释代码，是注释的本意。但是，我们有时候会过度依赖解释，从而放弃了潜在的替代方案，比如更准确的命名，更清晰的结构，更顺畅的逻辑等等。 注释，被我们用成万能的狗皮膏药，有时会让代码更糟糕。

比如，下面的代码和注释，看起来没毛病，但读起来很吃力。

String name1;  // first name

String name2;  // last name

如果使用准确、有意义的命名，我们就可以去掉没有意义的注释了。

String firstName;

String lastName;

还有一个麻烦，就是注释的滥用。 由于注释部分不被执行，那么就可以被用来注释掉一些不需要的东西。比如，在正式的产品代码中，注释掉调试信息、代码块、俏皮话等等。

比如说，看到下面的注释，你是不是立即就转移注意力了？ 我理解这个注释的初衷是幽默一下，但是众口难调，这样的注释有些人感觉到的不是幽默，而是散漫和业余。

// 哈哈，有没有人姓好，叫“好名字”？

String firstName;

String lastName;

讲了这么多，总结一下，注释是代码的一部分，是需要阅读的内容，目的是让其他人能更好地理解我们的代码，写注释需要我们有“用户思维”。虽然也有过度依赖注释的情况，但是，对于大部分程序员来说，问题还是注释太少，而不是太多。

几种常见注释类型

接下来，我们就聊聊几种常见的注释类型。一个典型的源代码文件，一般包含不同类型的注释。不同类型的注释，有着不相同的要求，适用于不同的注释风格和原则。

第一种类型，是记录源代码版权和授权的，一般放在每一个源文件的开头，说明源代码的版权所有者，以及授权使用的许可方式，或者其他的公共信息。比如，如果是个人的代码，版权信息可以写成：

/*

 * Copyright (c) 2018, FirstName LastName. All rights reserved.

 */

一般来说，版权和授权信息是固定的。版权和授权信息是法律条款，除了年份，一个字都不能更改。对于每个源代码文件，我们记得复制粘贴在文件开头就行。需要注意的是，如果文件有变更，记得更改版权信息的年份（比如上例中的 2018）。

第二种类型，是用来生成用户文档的，比如 Java Doc。 这部分的作用，是用来生成独立的、不包含源代码的文档。 这些文档帮助使用者了解软件的功能和细节，主要面向的是该软件的使用者，而不是该软件的开发者。 比如 Java 的 API 规范的文档。

第三种类型，是用来解释源代码的。换句话说，就是帮助代码的阅读者理解代码。这是大家默认的注释类型，也是我们今天讨论的重点。

简化注释的风格

上面我们介绍了三种常见的注释类型，下面就针对这三种注释类型，再给你介绍三种风格的注释。

针对第一种注释类型，也就是固定的版权和授权信息，使用一般的星号注释符（/-/）。注释块的首行和尾行只使用星号注释符，中间行以缩进一个空格的星号开始，文字和星号之间使用一个空格。注释的每行长度限制，和代码块的每行长度限制保持一致。

比如：

/*

 * Copyright (c) 2018, FirstName LastName. All rights reserved.

 */

针对第二种注释类型，即生成用户文档的注释，使用 Javadoc 要求的格式，文档注释符（/-*/）。 除了首行使用特殊的文档注释符（/），其他的格式和第一种风格保持一致。

比如：

/**

 * A {@code Readable} is a source of characters. Characters from

 * a {@code Readable} are made available to callers of the read

 * method via a {@link java.nio.CharBuffer CharBuffer}.

 *

 * @since 1.5

 */

public interface Readable {

   ...

}

针对第三种注释类型，也就是代码解释注释，只使用行注释符（//）。 每行长度限制，和代码块的每行长度限制保持一致。

比如：

// Verify that the buffer has sufficient remaining

private static void verifyLength(

        ByteBuffer buffer, int requiredLength) {

    ...

}

String myString;  // using end-to-line comment

// This is a multiple line comment.  This is a multiple

// line comment.

if (!myString.isEmpty()) {

   ...

}

写代码注释时，我一般只用这三种风格。它们各自有固定的使用范围，简单直观，规避不必要的代码错误。也不会让别人混淆注释的类型。

我不会使用如下的注释，因为这种注释风格可能和有效的代码混淆在一起。 注释越长，错误越容易隐藏起来。

/*

 * This is a multiple line comment.  This is a multiple

 * line comment.

 if (programingLanguage.equals("Java")) {

    ...

 } */

当然了，肯定有人会喜欢上述的风格，因为这种风格可以注释掉不用的代码。这一点，方便我们调试分段代码。我自己在调试的时候也喜欢使用这种注释方式，但是一旦调试结束，我就会清理掉这些注释。

从我自己的经验来看，养成这样的习惯很有帮助：如果一段代码不再需要，我会清理掉代码，而不会保留这个注释掉的代码块。不要在源代码里记录代码历史，那是代码版本管理系统该干的事情。

注释的三项原则

那么，用来解释源代码的注释有什么需要注意的地方吗？为了规避注释的种种麻烦，有没有什么原则我们必需要遵守呢？我总结了以下三点。

准确，错误的注释比没有注释更糟糕。

必要，多余的注释浪费阅读者的时间。

清晰，混乱的注释会把代码搞得更乱。

比如，当我们说编程语言时，一定不要省略“编程”这两个字。否则，就可能会被误解为大家日常说话用的语言。这就是准确性的要求。

如果代码已经能够清晰、简单地表达自己的语义和逻辑，这时候重复代码语义的注释就是多余的注释。注释的维护是耗费时间和精力的，所以，不要保留多余的、不必要的注释。

如果注释和代码不能从视觉上清晰地分割，注释就会破坏代码的可读性。

另外，不要在代码里标注你想要做的工作和已经做过的工作。比如使用 TODO，记录代码更改记录等等。这些信息会干扰代码的阅读者。

特别需要注意的是，我们可以使用临时的调试语句，但是，不要把代码的调试语句保留在提交的代码里。这些调试语句，既不容易维护，也不容易阅读。

注释用英文还是汉字呢？

你会注意到，上面的代码案例中，我基本使用的是英文注释，在这里我也建议你使用英文注释。

为什么呢？

因为使用中文注释，不是一个所有人都能接受的风格。一部分人，一部分公司，并不接受中文注释。特别是国际化的项目，比如说贡献给 Apache 的项目，就没有办法使用中文注释了。而且，如果是去面试，我也会尽最大的努力不使用中文注释，以免踩到坑。

除了接受度之外，汉字带来的真正困扰是，它会影响到编码风格的偏好。比如命名的问题，到底是应该使用拼音还是英文？ 由于代码命名只能使用 ASCII 字符，注释里的拼音、英文、汉字混杂的问题该怎么处理？代码编辑时，字符的切换也是一个麻烦事。比如，空格使用汉字全角，编译器会报错，但是肉眼看不到，问题排查起来也很心累。

那么什么情况下使用汉字呢？

面对国内的需求文档的时候。因为很多项目的需求文档一般是汉字书写的。程序的编码，当然需要按照需求来。如果需求的引用还要翻译成英文，那就太麻烦了。

还有一种状况，就是团队的英文水平不太好。与其使用难以读懂的蹩脚英文，不如使用大家更熟悉的中文注释来的便捷。不过，我对这种状况的担心越来越少，现在大部分年轻软件工程师的英语水平是可以让人放心的。

试着对比下面的几种注释，你喜欢哪一种呢？

上面的五种不同的风格，我个人比较喜欢第一种和第二种，第三种也可以接受。 但是我会尽量避免第四种和第五种风格。

总结一下，今天我们讨论了怎么写好注释这个话题，希望你能理解一个基本的原则：注释是用来提高代码的可读性和可维护性的。 不要让注释偏离了这个原则，破坏了代码的逻辑和可读性。你也可以实践一下我们讨论的“三项原则”和“三种风格”，看看能不能让你的代码变得更友好？

一起来动手

还记得我们上一节的练习题吗？前面，我们改了名字，改了编排。这一次，我们来修改注释。认真读一下这段代码，看看有需要增加或者修改注释的地方吗？欢迎你把优化的代码公布在讨论区，我们一起来感受修改后的代码是不是更好阅读，更好维护。

import java.util.HashMap;

import java.util.Map;

class Solution {

    /**

     * Given an array of integers, return indices of the two numbers

     * such that they add up to a specific target.

     */

    public int[] twoSum(int[] nums, int target) {

        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {

            int complement = target - nums[i];

            if (map.containsKey(complement)) {

                return new int[] { map.get(complement), i };

            }

            map.put(nums[i], i);

        }

        throw new IllegalArgumentException("No two sum solution");

    }

}

欢迎点击“请朋友读”，把这篇文章分享给你的朋友或者同事，一起来探讨吧！

08 | 写好声明的“八项纪律”

我们在前面讨论了该怎么取一个好名字。在编程语言里，我们使用标识符来表示不同的逻辑和对象。声明就是用来定义这些标识符的。标识符声明的地方，就是取名字和第一次使用名字的地方。这一次，我们聊一聊该怎么声明一个标识符。

“声明”是我们和标识符初次见面的地方，第一印象就显得特别重要。如果我们忘记了，回头能够清晰地找到它，也很重要。如果我们印象模糊了，回头能够重新认识它，对于我们阅读程序也有很大的帮助。

一个标识符，不仅仅只是一个名字。 像人分男女、高矮胖瘦一样，标识符也可以有附加信息，用来增强人们对它的认识。

一个声明，一般至少包含两个部分，一个是标识符的名字，一个是标识符的类型。 比如：

int size;

有的声明，还有修饰和限定部分，比如 Java 的访问控制修饰符（private，public 等）：

private int size;

或者 C 语言的存储类别限定符（auto，extern 等）：

auto int size;

写声明很简单，但写好声明也并非易事。我们以 Java 语言为例来讨论声明的编码风格。在 Java 语言里，声明可以用来定义类、方法、类变量、局部变量和常量。不同声明的语法有着巨大的差别，但是也有很多共通的地方，你可以把这些思路用在自己熟悉的语言上。

接下来，我会带你一起来学写好声明的“八项纪律”。

取一个好名字

既然是标识符，就涉及到取名字的问题。我们前面已经讨论过“怎么取好名字”这个话题了，你可以复习一下。

一行一个声明

我们在前面讨论过“一行一个行为”的代码整理规则。这一规则，同样适用于标识符的声明。不推荐在同一行里声明多个变量，即使这一行很短。

这样可以很方便地变更和维护代码。 比如，下面的两个代码变更，第二种格式更容易发现大意的错误。

- int size;

+ int size, length;

  int size;

+ int length;

下面这个代码变更例子，也是同理。

- int size, length;

+ int size;    // size of the list

+ int length;

- int size;

+ int size;    // size of the list

  int length;

另外，不要在同一行声明不同类型的标识符。

需要注意的是，表示数组的中括号“[]”是类型的一部分，而不是标识符的一部分。无论是 Java 语言，还是在 C 语言的代码中，我都建议把数组符号放在类型该在的地方。

局部变量需要时再声明

标识符的声明应该和它的使用尽可能地靠近，特别是局部变量的标识符声明。这样在视觉上，标识符的定义和使用，可以方便我们阅读和记忆。

比如，在下面的例子中，GroceryStore 的声明就太早了。如果代码再长一些，等我们读到代码的末尾时，前面声明的变量很可能都忘记了，如果还要返回来查看这个具体的声明，那就太麻烦了。

类属性要集中声明

同样是为了阅读和记忆，类变量的声明则要集中。因为类变量无论是私密变量，还是公开变量，在类的方法实现中，随时都可以调用。我们需要把这些变量放在一起，以便于修改和查找。

在下面的例子中，变量的声明散乱在类的代码块里。如果我们想要新加一个方法，或者调整方法声明的顺序，代码马上就会变得混乱不堪。

声明时就初始化

除非变量的初始值依赖于更多的条件，或者涉及到一定的计算，否则，声明时就应该完成初始化。声明时初始化，可以防止初始化的遗漏或者不必要的代码重复。

在下面的例子中，你可以设想一下，如果变量 _isNegotiated_ 是后来加入的属性，哪一种编码方式更容易变更、更容易维护？

尾随的花括号

一般来说，类声明和方法声明后，要使用花括号把实现的代码包括进来。花括号的使用语法很随意。我不建议代码中混杂地使用不同的格式。我只推荐一种方法：

左括号不要单独成行，要紧随在语句尾部，以一个空格隔开；

右括号单独一行。

靠紧的小括号

小括号的使用语法也可以很随意。小括号一般用来识别一个标识符是不是方法标识符，所以建议小括号要紧靠着标识符，中间不要有空格。

搜索优化的换行

搜索优化是我们编写代码时要考虑的一个因素。搜索优化既包括针对搜索引擎的优化（SEO），也包括针对编辑器（vi, Netbeans）以及系统工具（grep）的搜索优化。

常见的搜索模式有：

“public class”

“abstract class”

“class TheClassName”

“extends TheClassName”

“implements TheInterfaceName”

“theMethodName(”

这些常用的搜索模式给了我们一个很好的启示：语义相关的词语，常见的搜索模式，要尽量放在用一行。

小结

我们讲完了写好声明的八项原则，虽然相对来说比较细致，但基本的大原则主要有两个：

取好名字

容易识别

掌握住这两条大原则，你会慢慢积累更多的最佳实践案例和纪律细节，让自己的代码声明越来越好读、越来越好用。

一起来动手

所以为了让你更好地实践，我找了一段 Java 代码。你来试试看，怎么把这段代码改得更漂亮？欢迎你把优化的代码公布在讨论区，我们一起来看看这段代码都有哪些可以改进的地方。

import java.util.ArrayList;

import java.util.HashMap;

import java.util.List;

import java.util.Map;

class Solution {

    /**

     * Return a list of the words in words that match the given pattern.

     *

     * A word matches the pattern if there exists a permutation of

     * letters p so that after replacing every letter x in the pattern

     * with p(x), we get the desired word.

     *

     * Example:

     *     Input: words = ["abc","deq","mee","aqq","dkd","ccc"],

     *            pattern = "abb"

     *     Output: ["mee","aqq"]

     *

     *     Explanation: "mee" matches the pattern because there is

     *         a permutation {a -> m, b -> e, ...}.

     *

     *        "ccc" does not match the pattern because

     *        {a -> c, b -> c, ...} is not a permutation, since a

     *        and b map to the same letter.

     */

    public List<String> findAndReplacePattern(String[] words, String pattern) {

        List<String> ans = new ArrayList();

        for (String word: words)

            if (match(word, pattern))

                ans.add(word);

        return ans;

    }

    public boolean match(String word, String pattern) {

        Map<Character, Character> M = new HashMap();

        for (int i = 0; i < word.length(); ++i) {

            char w = word.charAt(i);

            char p = pattern.charAt(i);

            if (!M.containsKey(w)) M.put(w, p);

            if (M.get(w) != p) return false;

        }

        boolean[] seen = new boolean[26];

        for (char p: M.values()) {

            if (seen[p - 'a']) return false;

            seen[p - 'a'] = true;

        }

        return true;

    }

}

备注：代码选自https://leetcode.com/problems/find-and-replace-pattern/

欢迎你把这篇文章分享给你的朋友或者同事，一起来探讨吧！

09 | 怎么用好Java注解？

如果你使用面向对象的概念和技术有一段时间了，不知道你会不会有这样的困惑： 面向对象技术带来的麻烦，一点都不比它带来的好处少！

比如说，我们辛辛苦苦继承了一个类，重写了它的方法。过几天，这个类居然修改了它的接口，而且没人通知我们。然后，我们写的子类还可以美滋滋地编译，运行，就是总出错误，怎么调试都没发现这个子类的实现有什么不妥。直到有人说，父类变了！这时候，我们就想找杯咖啡暖暖手，一个人静静。

面向对象技术确实有它值得傲娇的地方。但是，只有把类似上面的小麻烦解决掉，它的使用才更合理。 比如说，父类做了修改，能不能立即就通知我？ 别等到问题出现了，我们还被蒙在鼓里。

Java 注解就可以帮助我们。

什么是 Java 注解

Java 注解是 Java 1.5 引入的一个工具，类似于给代码贴个标签，通过注解可以为代码添加标签信息。这些标签信息可以添加在字段、方法和类上。开发工具、部署工具或者运行类库，可以对这些标签信息进行特殊的处理，从而获得更丰富的功能。

经过十多年的发展，注解已经成了 Java 生态系统一个非常重要的技术。使用注解可以大幅度降低我们的开发强度，提高工作效率，减少潜在的错误。像 Java 类库一样，注解也有了越来越丰富的定义和规范，成了我们需要掌握的重要技术之一。

我们这里只讨论编写规范的代码时，该怎么合理地使用注解，具体就是 Override、Deprecated、SuppressWarnings 这三个注解。更详细的 Java 注解技术和规范，以及如何自定义注解，需要你参考相关的文档。

在声明继承关系中，Java 注解该如何使用？

在代码编写中，继承和重写是面向对象编程的两个重要的机制。这两个机制，在给我们带来便利的同时，也顺便带来了一些麻烦，这就需要我们用到注解了。

第一个麻烦是，识别子类的方法是不是重写方法。比如下面的例子，在一般情况下，对代码阅读者来说，最直觉的感受就是，getFirstName() 这个方法不是重写方法，父类 Person 没有定义这个方法。

class Student extends Person {

    // snipped

    public String getFirstName() {

        // snipped

    }

    // snipped

}

通常如果一个方法是重写方法，一定要使用 Override 注解，清楚地标明这个方法是重写的方法。 使用 Override 注解的另一个好处是，如果父类更改了方法，子类的编译就会出错。这样我们就能在第一时间获得通知，既可以及时地变更子类，也可以使父类的变更更加合理。

class Student extends Person {

    // snipped

    @Override

    public String getFirstName() {

        // snipped

    }

    // snipped

}

为什么要识别重写方法呢？这是因为继承的第二个麻烦。

第二个麻烦是，重写方法可以不遵守父类方法的规范。面向对象编程的机制，理想的状况是，父类定义了方法和规范，子类严格地遵守父类的定义。 比如 Person.getFirstName() 要求返回值是一个人的名，不包括姓氏部分，而且不可以是空值。但是子类 Student.getFirstName() 的实现完全有可能没有严格遵守这样的规范，不管是有意的，或者是无意的。 比如，返回了姓氏，或者返回了包括姓氏的姓名，或者可以返回了空值。

class Student extends Person {

    // snipped

    @Override

    public String getFirstName() {

        return null;

    }

    // snipped

}

编译器无法检查重写到底该怎么实现，保持重写方法的行为一致需要我们凭借经验、肉眼识别。一般来说，一个重写方法不应该改变父类定义的规范。如果的确需要改变，就要有充足的理由，以及面对潜在兼容问题的具体的解决办法。

比如上面的例子中，如果 Person.getFirstName() 不允许返回空值，应用程序可以很安心地使用返回值，而不需要检查空值。

boolean isAlice(Person person) {

  return person.getFirstName().equals("Alice");

}

但是，有了可以返回空值的 Studen.getFirstName() 的重写，上面的代码就可能抛出 NullPointerException。一段简单的、严格遵守规范的代码，就变得危机四伏。

既然需要肉眼的判断，第一步就是要识别出重写方法。 识别方法越简单越好。

所以，重写的方法，一定要加上 Override 注解。这个注解，既可以提醒代码的阅读者，也提醒代码的书写者，要谨慎对待该方法在父类定义的规范。

识别出重写方法后，第二步就要判断重写的方法和父类规范的定义有没有冲突和抵触。

虽然一般情况下，子类的重写方法不应该改变父类的规范。但是，编写代码处处充满了无奈和妥协。极少数情况下，除了变更方法的规范，我们可能别无选择。 一旦这种情况发生，一定要明确标明，并注明潜在的后果。

如果重写方法既没有改变父类规范，也没有其他情况需要重点说明，重写方法就不应该有规范描述部分的存在。这样，可以减少规范描述对于阅读者的误导。我们当然需要了解具体的规范，但是应该查找、阅读父类的规范描述。

继承和重写还有一些其他的麻烦，我们后面的章节接着再聊。

在废弃退役接口的情况下，如何使用注解？

一个软件，部署得越广泛，生命力越悠久，就越需要不断地改进、升级。而废弃不合理的设计，拥抱更新的思想，也是软件改进的一部分。

然而，软件接口的废弃，不是一件简单的事情。越是广泛使用的接口，它的废弃、退役越困难。

比如，下面的 String 构造方法，是 1994 年 Java 1.0 设计实现的方法。很快，人们发现了这个方法的局限性。在 1997 年发布的 Java 1.1 中，废弃了该构造方法以及其他相关的方法。到现在，已经废弃 20 多年了，但 Java 依然没有删除这些方法，因为 String 的使用太广泛了！

@Deprecated(since="1.1")

public String(byte ascii[], int hibyte) {

    this(ascii, hibyte, 0, ascii.length);

}

无论对于软件的维护者，还是软件的使用者，废弃的接口都是不值得让我们继续耗费精力的。

如果软件的维护者继续在废弃的接口上投入精力，意味着这个接口随着时间的推移，它的实现可能会存在各种各样的问题，包括严重的安全问题，就连使用者也要承担这些风险。而且还会有用户持续把它们运用到新的应用中去，这就违背了废弃接口的初衷。更多的使用者加入危险的游戏，也增加了删除废弃接口的难度。

这就要求我们做好两件事情。

第一件事情是，如果接口的设计存在不合理性，或者新方法取代了旧方法，我们应该尽早地废弃该接口。

及时止损！

做好这件事情，需要我们使用 Deprecated 注解，并且用一切可以使用的办法，广而告之。对于代码而言，要在声明中使用 Deprecated 注解；在规范描述中，说明废弃的原因以及替代的办法；对于有计划要删除的接口，要注明计划删除的版本号。

下面是两个可以参照的 Java 代码废弃接口的例子：

java/lang/String.java:

/**

 * Counts the number of stack frames in this thread. The thread must

 * be suspended.

 *

 * @return     the number of stack frames in this thread.

 * @throws     IllegalThreadStateException  if this thread is not

 *             suspended.

 * @deprecated The definition of this call depends on

 *             {@link #suspend}, which is deprecated.  Further,

 *             the results of this call were never well-defined.

 *             This method is subject to removal in a future

 *             version of Java SE.

 * @see        StackWalker

 */

@Deprecated(since="1.2", forRemoval=true)

public native int countStackFrames();

java.security.Certificate.java:

/**

 * <p>This is an interface of abstract methods for managing a

 * variety of identity certificates.

 *

 * ... snipped ...

 *

 * @deprecated This class is deprecated and subject to removal

 *     in a future version of Java SE. It has been replaced by

 *     {@code java.security.cert.Certificate} and related classes.

 * @see java.security.cert.Certificate

 */

@Deprecated(since="1.2", forRemoval=true)

public interface Certificate {

    // snipped

}

第二件事情是，如果我们在现有的代码中使用了废弃的接口，要尽快转换、使用替换的方法。等到废弃方法删除的时候，再去更改，就太晚了，不要等到压力山大的时候才救火。

如果一个接口被废弃，编译器会警告继续使用的代码。Java 提供了一个不推荐使用的注解，SuppressWarnings。这个注解告诉编译器，忽略特定的警告。警告是非常有价值的信息，忽略警告永远不是一个最好的选项。

再次强调，除非万不得已，不要使用 SuppressWarnings。如果万不得已来临，请参考下面的例子。

@SuppressWarnings("deprecation")

private boolean myMethodUseDeprecatedMethod() {

  // snipped

}

当然，这样的使用带来了一系列的后遗症。 由于，废弃的编译警告被无视，我们使用了废弃接口的事实就被淹没在代码的海洋里，再也进入不了我们的视野。不到废弃接口被删除的那一天，我们都意识不到我们的代码里使用了废弃的接口，我们的应用程序都要承担着废弃接口维护不足的种种风险，包括严重的安全风险。

后面我们还会谈到，不要轻易地更改现有的代码，即使这些代码很丑陋，散发着浓浓的腐臭味。但是，有一个例外，如果看到了使用 SuppressWarnings 的代码，我们要尽可能地想办法把相关的警告消除掉、把这个注解去掉，越快越好。

小结

Java 注解的功能很丰富，了解注解可以使得我们编码的工作更轻松。 这一次，希望我们记住三个基本的实践：

重写的方法，总是使用；

过时的接口，尽早废弃；

废弃的接口，不要使用。

一起来动手

Java 的注解非常丰富，功能也很强大。借这个机会，我想让大家互相分享一下，你最经常使用的注解是什么？什么情况下使用这个注解？这个注解给你带来哪些便利？欢迎你把你的经验发布在评论区，我们一起来学习更多的注解，一起来进步。

也欢迎你把这篇文章分享给你的朋友或者同事，一起来探讨吧！

10 | 异常处理都有哪些陷阱？

上一讲中我们聊了聊怎么用好 Java 注解，今天我们谈谈怎么处理异常。

处理好异常状况是掌握一门编程语言的基础，也是我们编程离不开的基本功。相信你对异常处理的机制已经很熟悉了。异常处理便捷、灵活、好用。但是，越好用的东西，我们越容易忽视它的缺陷。异常处理就有很多我们容易忽视的陷阱。今天，我们来聊聊这些问题，以及该怎么处理这些问题。

异常就是非正常

异常状况，就是不正常的状况。

异常状况的处理会让代码的效率变低。一个流畅的业务，它实现代码的执行路径，理想的状况就是没有任何异常状况发生。否则，业务执行的效率就会大打折扣。既然这样，我们就不应该使用异常机制来处理正常的状况。

这一点不难理解。可是，由于一门语言无法理解什么样的状况是正常状况，什么样的状况又是异常状况，也就无法限制异常机制使用的具体场景。所以作为程序员，我们需要自己解决好这个问题，不要滥用了异常机制。

比如说，很多 API 的设计有检查参数有效性的方法。如果参数通过检验，就没有异常抛出，否则就会抛出异常。在使用这个方法的代码时，我们需要检查有没有抛出异常来确认参数是否有效。

/**

 * Check if the user name is a registered name.

 *

 * @throws IllegalArgumentException if the user name is invalid or

 *         not registered.

 */

void checkUserName(String userName) {

    // snipped

}

这是一个糟糕的设计！

在这个例子中，如果 userName 字符串不符合规范，这是一个异常状况； 如果 userName 不是一个注册用户，这通常是一个正常状况。 在正常状况下使用异常处理，无疑会降低系统的效率，以及编码的效率。

所以正常的状况和异常的状况一定要分开、分清，不能混用。

/**

 * Check if the user name is a registered name.

 *

 * @return true if the userName is a registered name.

 * @throws IllegalArgumentException if the user name is invalid

 */

boolean isRegisteredUser(String userName) {

    // snipped

}

分清异常的类别

我们上面讲了异常的一些基本概念，现在我们来讲讲异常的类别。在 Java 语言里，异常状况分为三类。

非正常异常（Error）：这类异常的命名以 Error 结尾，比如 OutOfMemoryError，NoSuchMethodError。这类异常，编译器编译时不检查，应用程序不需要处理，接口不需要声明，接口规范也不需要纪录；

运行时异常（RuntimeException）：这类异常的命名通常以 Exception 结尾，比如 IllegalArgumentException，NullPointerException。这类异常，编译器编译时不检查，接口不需要声明，但是应用程序可能需要处理，因此接口规范需要记录清楚；

非运行时异常：除了运行时异常之外的其他的正常异常都是非运行时异常，比如 InterruptedException，GeneralSecurityException。和运行时异常一样，命名通常以 Exception 结尾。这类异常，编译器编译时会检查异常是否已经处理或者可以抛出，接口需要声明，应用程序需要处理，接口规范需要记录清楚。

通常我们说的异常，指的是运行时异常和非运行时异常。对于非正常异常，我们通常使用“错误”这个词汇而不是“异常”来表示。 由于非正常异常不需要特别处理，使用“错误”这个词汇，并不会导致使用方面的误导。

非运行时异常还有一个别名，叫作检查型异常（CheckedException）。对应地，运行时异常的别名是非检查型异常（UncheckedException）。 为了便于识别，我们通常使用检查型异常指代非运行时异常，使用运行时异常指代非检查型异常。

我们来看看两个例子，感受下运行时异常和检查型异常不同的处理方式。

下面这个例子中，IllegalArgumentException 是运行时异常。虽然方法的声明中没有出现 IllegalArgumentException，但是在方法的规范中，需要使用记录什么情况下抛出该异常。只有这样，方法的调用者才能知道什么时候异常会抛出，该采取什么样的处理办法。

/**

 * Check if the user name is a registered name.

 *

 * @return true if the userName is a registered name.

 * @throws IllegalArgumentException if the user name is invalid

 */

boolean isRegisteredUser(String userName) {

    // snipped

}

下面这个例子中，CloneNotSupportedException 是检查型异常。这样的异常，一定要出现在对应方法的声明中。

/**

 * Returns a clone if the implementation is cloneable.

 *

 * @return a clone if the implementation is cloneable.

 *

 * @throws CloneNotSupportedException if this is called on an

 *         implementation that does not support {@code Cloneable}.

 */

public Object clone() throws CloneNotSupportedException {

    // snipped

}

这三类异常的处理，可以用下表来总结。

标记清楚抛出异常

应用程序需要处理异常（CheckedException 和 RuntimeException），就需要我们在方法的规范描述文档中清楚地标记异常。没有标记的异常，应用程序没有办法通过文档了解哪些异常需要处理、什么状况下会抛出异常以及该怎么处理这些异常。

对于检查型异常，编译器或者 IDE 会友好地提醒使用合适的声明。我们一般不会遗漏检查型异常的声明。既然声明不会遗漏，异常的标记也通常不容易遗漏。 比如上面 clone() 方法的例子，CloneNotSupportedException 已经在方法定义部分声明了。在方法规范描述部分，只要不遗漏这个异常的描述就好了。

然而，对于运行时异常，我们就没有这么幸运了。目前我们使用的编译器或者 IDE，还没有提醒运行时异常遗漏的功能。由于没有工具的帮助，我们就很难发现运行时异常，这就很容易导致代码效率降低，错误增多。

我举个例子，在上面的检查用户名的例子中，如果我们不在方法的规范描述中记录抛出的运行时异常，该方法的使用立即就会遇到问题。

/**

 * Check if the user name is a registered name.

 *

 * @return true if the userName is a registered name.

 */

boolean isRegisteredUser(String userName) {

    // snipped

}

其中最常见的问题包括：

如果参数 userName 是一个无效引用（null），会发生什么状况，该怎么处理？

如果参数 userName 是一个空字符串（“”），会发生什么状况，该怎么处理？

如果参数 userName 不是一个规范的用户名，会发生什么状况，该怎么处理？

每一个问题，都会降低使用者的效率，让使用者陷入难以摆脱的困扰。

如果代码的层次结构再多一层，这个问题就会更加严重：

/**

 * Check if the {@code userName} is an authenticated user.

 *

 * @return true if the {@code userName} is an authenticated user.

 */

boolean isAuthenticatedUser(String userName, String password) {

    // WRONG, should catch or describe the thrown exception.

    if (isRegisteredUser(userName)) {

        // snipped

    } else {

        // snipped

    }

    // snipped

}

如果一个方法既没有异常的声明，又没有异常的规范描述，调用者一般不会进行异常处理，也不在规范描述中加入抛出异常的描述。 这样的层次结构，只要稍微多个一两层，运行时异常虽然在代码和规范描述层面消失得无影无踪，但它并没有真正消失，依然会在运行时准时出现。

即使调用者拥有源代码，可以阅读源代码，也不容易意识到有运行时异常需要谨慎对待。代码的阅读者也不会有足够的精力和动力去深挖所有的层次，来确认有没有运行时异常。

由于编译器或者 IDE 并不能给我们太多的帮助，我们更需要谨慎、严格地处理好运行时异常。具体该怎么做呢？

对于所有的可能抛出运行时异常，都要有清晰的描述，一个也不要错过；

查看所有的调用方法的规范描述，确认抛出的异常要么已经处理，要么已经规范描述。

如果我们都遵循了这两条运行时异常处理的办法，我们只需要查看一层代码的异常就可以了，这样无论是编码还是阅读代码，都会轻松不少。

处理好捕获异常

要想处理好异常，我们需要了解异常机制的基本原理。 我们一起回顾一下 Java 异常的四个要素：

异常类名（IllegalArgumentException， FileNotFoundException）

异常描述（“Invalid file path”）

异常堆栈（at sun.security.ssl.InputRecord.read(InputRecord.java:504)）

异常转换（Caused by: javax.net.ssl.SSLException: Unrecognized SSL message, plaintext connection?）

这四个要素满足了三个现实的异常处理问题：

出了什么错？

什么地方出了错？

为什么会出错？

其中，异常类名解决了“出了什么错”的问题；异常描述解决了“为什么会出错”的问题；异常堆栈解决了“什么地方出了错”的问题；而异常转换记录了不同场景对这三个问题的不同理解和不同处理。

其中 JVM 自动帮我们处理异常堆栈，我们需要特别关注的就只有三点了。

1. 对于异常类名，我们要准确地选择异常类。

Exception 类是一个包罗万象的超级异常类，如果我们使用 Exception 作为声明和抛出的异常，就不方便用户精准定位，从而解读和判断“出了什么错”。 类似的超级异常类还有 RuntimeException、IOException 等。 除非是超级的接口，否则我们应该尽量减少超级异常类的使用，而是选择那些意义明确、覆盖面小的异常类，比如 FileNotFoundException。

2. 对于异常描述，我们要清晰地描述异常信息。

虽然 Java 异常允许使用没有具体异常信息的异常，但是这种使用却容易丢失用户对于“为什么会出错”这个问题更精准的解读。 所以我不推荐使用没有描述信息的异常。

3. 对于异常转换，我们要恰当地转换异常场景。

随着应用场景的转换，我们还需要转换异常的类型和描述。 比如，SQLException 这种涉及具体实现细节的异常类就不太适合直接抛给最终的用户应用。 用户关心的是商业的逻辑，并不是实现的细节，这就需要我们随着使用场景调整异常。如果一股脑儿地把所有的异常抛到底，业务逻辑就会很混乱，用户体验也不好。

但是随着场景调整异常也不是没有代价的。这是一个妥协的选择，会带来一些负面的情况。

第一个情况，就是需要编写转换的代码，这当然没有异常一抛到底方便。

第二个情况，就是信息的冗余。如果转换场景有两三层，异常打印出来的堆栈信息就会很长，而最有用的信息其实只有最原始的异常。

第三个情况，就是信息的丢失。有些信息的丢失是有意的，比如对敏感信息的过滤而丢掉的异常信息。有些信息的丢失是无意的过失。信息的丢失很难让我们排查出异常问题，于是错误的源头被硬生生地隐匿了起来。所以，除非有明确的需求，我们要尽量保留所有的异常信息以及转换场景。

由于这些负面情况的存在，我们就更要谨慎地使用异常场景转换，特别是要认真权衡信息丢失的问题。

小结

今天我给你讲了处理异常的一些情况，它的小陷阱很多。单就编写规范的代码来说，我们要记住三条准则：

不要使用异常机制处理正常业务逻辑；

异常的使用要符合具体的场景；

具体的异常要在接口规范中声明和标记清楚。

你还知道哪些异常处理的陷阱呢？欢迎你分享在留言区，我们一起来学习。

一起来动手

下面的这段 Java 代码，它的异常处理有没有违反我们上面讨论的原则？你能够改进下面的代码吗？

欢迎你把优化的代码公布在讨论区，我们一起来讨论，看哪些地方我们可以做得更好。也欢迎你把今天的内容分享给协作的小伙伴，和他一起进步。

import java.util.HashMap;

import java.util.Map;

class Solution {

    /**

     * Given an array of integers, return indices of the two numbers

     * such that they add up to a specific target.

     */

    public int[] twoSum(int[] nums, int target) {

        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {

            int complement = target - nums[i];

            if (map.containsKey(complement)) {

                return new int[] { map.get(complement), i };

            }

            map.put(nums[i], i);

        }

        throw new IllegalArgumentException("No two sum solution");

    }

}

11 | 组织好代码段，让人对它“一见钟情”

当我们看到一个事物的时候，它的轮廓首先进入视野，给了我们第一印象。如果第一印象没有吸引到我们，那我们就不会集中注意力去关注它，也不会想去认识它。

我觉得有个俗语非常好地概括了这个认知习惯。这个俗语就是“不起眼”，更通俗一点的说法是“放在人群里认不出来”。

不管我们愿不愿意，第一印象特别影响我们的判断和心情。我们看到美好的东西，自己也跟着高兴；看到乱糟糟的东西，自己也感觉乱糟糟的。

代码也是这样的。如果我们看到整齐、清爽的代码，我们就对它有好感，愿意阅读，也愿意改进。 如果代码一团糟，风格混乱，我们就特别想静静地走开，一行都不想看。

前面的几讲中，我们讨论了注释、声明、格式、注解等编码规范。现在，是时候把这些零散的知识放到一块儿了。这些规范组合在一起，就会让代码既有让人喜悦的轮廓，也有让人清爽的细节。

这一次，我们将使用大量的例子，从代码文件头部结构、对象结构、类的内部结构、方法的代码结构、限定词的使用以及空行的使用这六大维度，来说明到底该怎么组织一个源代码文件。

代码文件头部结构

一般来说，一个源代码文件的开始部分包含三项内容。 按照出现顺序，分别为：

版权和许可声明；

命名空间（package）；

外部依赖（import）。

下面就是一个例子。

在版权部分中，“2003, 2013”中的 2003 表示的是代码的诞生日期，2013 表示的是代码的最近更改日期。需要注意的是，每次代码变更，都要更改代码的最近更改日期。

代码文件对象结构

紧随着源代码的头部，是对目标类的定义，这一部分包含三个部分。 按照出现顺序，分别为：

类的规范；

类的声明；

类的属性和方法。

这一部分中，我们需要注意的是，对于公共类，需要使用 since 标签，标明从哪一个版本开始定义了这个类。 这样的标明，方便我们对类进行版本管理，减少我们进行代码变更时的工作量。

比如在上面的例子中，如果需要修改 Readable，当看到“since 1.5”的标签时，我们就不需要检查 Java 1.4 的代码了。

你也许会问，为什么 Java 1.4 不能使用 Readable 呢？since 标签同样可以给你答案，因为 Readable 是从 Java 1.5 开始才有的接口。

这些问题虽然简单，但是如果没有使用 since 标签，答案就没有这么直观明显了。

类的内部代码结构

类的属性和方法，一般是一个代码文件的主要组成部分。类的结构，推荐的编码顺序依次为：

类的属性；

构造方法；

工厂方法；

其他方法。

我摘抄了一段 JDK 的代码，你看这样是不是更干净整洁？

上面的代码案例中没有用到工厂方法，我来讲一下这个。

类似于构造方法，工厂方法也是用来构造一个类的实例。不同的是，工厂方法有具体的返回值。它可以是静态方法，也可以是实例方法。

如果是实例方法，工厂方法还可以被子类重写。这是工厂方法和构造方法的另外一个重要的区别。由于工厂方法也扮演着构造类实例的功能，我们一般把它放在构造方法的后面，其他方法的前面。

方法的代码结构

讲完了类的内部代码结构，我们再来讲讲方法的代码结构。一般来说，一个方法需要包含三项内容：

方法的规范；

方法的声明；

方法的实现。

内部类的内部方法，可以没有第一部分。但对于公开类的公开方法，方法的规范一定不能缺失。 一个典型的规范，应该包含以下十个部分：

方法的简短介绍；

方法的详细介绍（可选项）；

规范的注意事项 (使用 apiNote 标签，可选项)；

实现方法的要求 (使用 implSpec 标签，可选项)；

实现的注意事项 (使用 implNote 标签，可选项)；

方法参数的描述；

返回值的描述；

抛出异常的描述：需要注意的是，抛出异常的描述部分，不仅要描述检查型异常，还要描述运行时异常；

参考接口索引（可选项）；

创始版本（可选项）。

下面的这个例子，也是来自 JDK 的源代码。你可以清晰地看到，这段代码中的规范是非常典型的。

如果方法的创始版本和它所属类的创始版本一致，方法的创始版本描述可以省略。 要不然，一定要加入方法的创始版本标签。

像下面这个例子，就添加了创始版本标签。

按顺序使用限定词

在声明一个类、方法或者方法属性时，为了更准确地描述和理解声明的适用场景，我们通常要使用修饰性的关键词。这些修饰性的关键词，我们通常称它们是修饰符或者限定词。 一个声明，可以使用多个限定词。

Java 的语法中，限定词的使用顺序没有强制性规定。但是，限定词的使用顺序有一个约定俗成的规则。按照这个规则使用限定词，一般来说，我们最关切的修饰符最先进入我们的视野，和标识符最密切的位置最靠近标识符。使用一致性的顺序，我们就能更快速地理解一个声明。

限定词推荐使用顺序：

public/private/protected （访问控制限定词，制定访问权限）

abstract （抽象类或者抽象方法，具体实现由子类完成）

static （静态类、方法或者类属性）

final （定义不能被修改的类、方法或者类属性）

transient（定义不能被序列化的类属性）

volatile（定义使用主内存的变量）

default（声明缺省的方法）

synchronized（声明同步的方法）

native（声明本地的方法，也就是 Java 以外的语言实现的方法）

strictfp（声明使用精确浮点运算）

使用空行分割代码块

我们之前讲过怎么整理代码，一个重要的原则就是“给代码分块”，通过空格、缩进、空行实现这个目的。

再来回顾一下空行的作用，空行用于垂直分割，用于分开同级别的不同代码块。

我们可以使用空行分割如下的代码块：

版权和许可声明代码块；

命名空间代码块；

外部依赖代码块

类的代码块；

类的属性与方法之间；

类的方法之间；

方法实现的信息块之间。

小结

对于软件开发者来说，组织代码是一项基本技能，也是我们需要养成的好习惯。组织代码有许多不同的习惯和策略，我们要学会辨别这些策略中哪些是有效的，哪些是有害的。

怎么辨别呢？

和其他技能一样，最快的提升方法是仔细思考一下为什么我们要做出这样的选择，而不是其他的。知其然远远不够，还要知其所以然。

你可以试着看看你的项目，源代码是按照这种方式组织的吗？哪些部分采用了合理的组织方式，哪些部分还有改进的空间？哪些是值得分享的经验？欢迎你把想法分享在留言区，我们一起来学习。

一起来动手

下面的这段 Java 代码，我们已经很熟悉了。前面，我们对它做过很多方面的修改。这一次，我们把前面的修改集中起来。你试着去找到所有可以改进的地方，然后比较一下修改前和修改后的代码。你有什么感受？

欢迎你把优化的代码公布在讨论区，让我们一起来感受、来欣赏！

也欢迎点击“请朋友读”，把这篇文章分享给你的朋友或者同事，一起来体验修改代码的快感。

import java.util.HashMap;

import java.util.Map;

class Solution {

    /**

     * Given an array of integers, return indices of the two numbers

     * such that they add up to a specific target.

     */

    public int[] twoSum(int[] nums, int target) {

        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {

            int complement = target - nums[i];

            if (map.containsKey(complement)) {

                return new int[] { map.get(complement), i };

            }

            map.put(nums[i], i);

        }

        throw new IllegalArgumentException("No two sum solution");

    }

}

12丨组织好代码文件，要有“用户思维”

上一讲中，我们讲了如何组织代码段，今天我来讲下，如何组织代码文件。

最开始接触一个项目代码时，我们最渴望的，就是快速揭开项目的面纱。这个项目是干什么的？是怎么做的？该怎么使用？

有很多这样的问题，排着队等我们处理。我们需要从一个点开始，先撕破一点点皮，然后，像剥洋葱一样，一层一层地阅读，一层一层地了解。

刚拿到一个项目的代码时，你最想找哪一个文件？面对大量的文件，该从哪里入手？创建一个项目时，各式各样的文件该怎么规整？

如果一个项目很小，只有三五个文件，我们不用担心上述的问题。

但事实上，一个典型的软件项目，拥有上百个文件是寻常的事情。如果这些文件组织混乱，就会让整个项目乱糟糟的，我们很难入手去查找、阅读和测试。

其实文件的组织是一个简单的事情，但这些简单的事情如果不能做得清晰、明了，就会变成一个效率的黑洞。

文件的组织要层次分明、易于检索、一目了然。要做到这一点，我们可以从用户思考问题的逻辑入手。

逻辑之一：软件是干什么的？

无论我们开始一个软件项目，还是阅读一个软件的代码，第一个遇到的问题就是，这个软件是干什么的？

可以回答这个问题的文件叫做 README，它的命名全部使用大写字母。需要被放在一个软件工程的根目录里，方便人或者机器第一时间找到，从而轻而易举地找到并进行阅读。

“软件要用来干什么？”是一个软件工程的启动问题。

一个软件项目开始时，这个问题的答案可以不是很丰满，但是，最基本的思想一定要有。随着软件的研发进程，它的描述可以越来越清晰。软件成型之前，这个问题必须干脆地解决掉，得到明确的答案。

这个问题的描述，要记录在代码的工程里。可以让代码的阅读者轻而易举地找到并阅读。

由于机器的参与，尤其是在线代码管理服务（比如 GitHub、Maven）的广泛使用，README 的名字和位置也就慢慢地形成了共识。

逻辑之二：软件可以拿来用吗？

如果我们看到了 README，想使用这个软件，那么紧接着的问题就是，这个软件我们可以使用吗？

所有的软件，都有归属，都受版权的保护。谁拥有这个软件的版权？这是我们需要关注的一个问题。

有时候，一个软件包含很多贡献者，不同的贡献者有不同的版权诉求。软件的不同部分，就有不同的版权。

这种情况下，版权描述一般放在每一个源文件的头部。不同的源文件可以有不同的版权，同一个源文件也可以有一个以上的版权所有者。

如果版权来源只有一个，而且源文件头部没有版权描述，我们就需要把版权描述放到最显眼的地方。这个地方就是软件工程的根目录，命名为 COPYRIGHT，全部使用大写字母。

没有版权描述的软件，并不是没有版权保护。如果一个软件没有版权描述或者版权描述不清晰，使用起来有很多法律风险。如果这个软件依赖外部的版权，那么问题就会变得更为复杂。

有了版权保护，不代表我们就不能使用这个软件了。我们能不能使用、怎么使用，是由软件的许可证确定的。

许可证文件是 LICENSE，全部使用大写字母，放在软件工程项目的根目录下。

当使用软件的时候，不能超越许可证约定的范围。 一个没有许可证的软件，我们是不能使用的，因为不知道许可的范围，也不知道应承担的义务。同样，如果一个软件的许可证不清晰，或者我们不了解，那么使用起来也会有很多法律问题。

逻辑之三：软件是怎么实现的？

作为程序员，代码是我们看软件世界的语言。我们关心的下一个问题就是，这个软件是怎么实现的？

代码的位置在现在的软件工程里有了一定的共识。通常来说，源代码存放在根目录下的 src 目录下。

当看到 src 目录的时候，我们就可以确认，这里面是源代码文件。当我们要查找源代码的时候，也是在软件工程文件里查找 src 目录。我不建议在这里搞创新，不要使用其他的名字或者位置。

但源代码并不能一股脑地堆在 src 这一个目录里。src 目录下面可以有很多子目录。一般来说，稍具规模、相对正规的软件，都需要有命名空间的区隔。使用命名空间的区隔至少有三个好处：

可以把一个组织的代码和另外一个组织的代码区隔开；

可以把一个项目的代码和另外一个项目的代码区隔开；

可以把一个模块的代码和另外一个模块的代码区隔开。

每一个命名空间的节点，都要对应一个文件目录。比如，我们常用的 java.lang 这个命名空间，就要相应地使用“java/lang”这两级目录。

如果软件项目把所有的源代码文件不加分别地放在同一个目录下，说明软件的开发人员并没有厘清代码之间的逻辑关系。纠缠在一起的代码越多，代码越难以维护，代码的安全越难以保证。

逻辑之四：软件该怎么测试？

如果要使用或者发布一个软件，最值得关注的还有软件的质量。软件的质量，首先要通过软件测试这一关。那么软件该如何测试呢？ 这是我们要面对的另一个问题。

有很多传统的软件项目，测试代码和功能代码是放在同一个目录下的。如果一个项目比较小，那么这样做也许没什么大问题。一旦软件工程变得复杂，这样做就会让项目变得难以管理，尤其是在测试人员和开发人员分离的情况下。

让测试人员看着实现代码写测试，会误导测试的用例选择和测试效果；让开发人员看着测试代码改实现，也会影响开发的质量和效率。

既然要分工，不同的代码就要区隔开来。

如果开发和测试是一个人，或者是同一个小组成员，是不是就可以混在一起了呢？ 当然不是，因为看代码的人依然可能是分工的。区隔实现和测试，可以尽可能照顾到每个工程师，方便他们工作。

幸运的是，现在的很多软件项目，大都采用了分离的组织方式。通常来说，源代码要存放在根目录下的 test 目录下。

仅仅放置在对应的目录下还不够，测试文件本身还有一个需要注意的地方，一个测试文件，最好执行一个独立的任务。如果测试出错误，我们就能够快速定位错误。这也要求测试的目标要小，测试的结果要清晰，测试的组织要照应功能代码的组织。

比如说，对 java.io 测试的文件，要放在 java/io 测试目录下；对 java.util 测试的文件，要放在 java/util 目录下。这种互相照应的组织方式，从目录名就可以看出测试的范围。这样既可以快速从功能代码找到测试代码，也可以快速地从测试代码找到功能代码。

软件该怎么使用？

使用软件工程项目文件的，不仅仅只有程序员，还有软件的用户。

要是只能通过阅读软件代码来揣测软件该怎么使用，这样既没有效率，也容易犯错，更偏离了软件设计者的初衷。

一个好的软件，要尽可能降低使用门槛。编写使用指南和代码示例是两个常用的办法。一份好的用户文档，应该让软件的用户快速入门，然后再逐步深入地了解整个软件的使用细节，以及潜在的问题。

软件的文档，需要随着软件的变更不断升级维护。有很多项目会把文档和代码分开管理。

但实际上，这样做有一些缺陷，它会让文档变得难以维护。

我们经常看到很多文档和软件脱节的软件，很大一部分是由于项目组织和管理方面的问题导致的。如果代码的变更，也需要相应地变更文档，那么文档和代码统一管理，是一个更高效的组织方式。

如果你留意，就会注意到现在的很多项目，在根目录下，有一个名字为 docs 或者 doc 的目录。 这个目录就是存放软件文档的。

作为程序员，我们不仅要熟悉源代码，还要熟悉文档。当需要更直观的用户指南或者代码示例时，就要写作这样的软件文档。对于每一行的代码变更，我们都要问，需不需要文档变更？如果代码和文档一致的话，就会节省我们大量的维护时间和维护成本。

下面的例子，是一个常见的软件组织形式，也是我们对上述讨论的小结。

Project directory layout:

   src/

      java/

          lang/

          io/

      javax/

          net/

              ssl/

   test/

      javax/

          net/

              ssl/

   doc/

   make/

   README

   COPYRIGHT

   LICENSE

小结

我们的日常软件开发工作，有很多都依赖于集成开发环境（IDE）。主流的 IDE 有缺省的文件组织形式。一般情况下，我们可以使用缺省的组织形式，然后添加进缺失的内容。

也有很多软件开发，不依赖于 IDE。这就需要我们自己规划好文件的组织原则和基本形式。不同的语言，不同的项目，文件的组织方式差别可能很大。

如果你需要自己制定组织形式，我建议参考一些成功项目的组织方式。比如，如果你要做一个中间件，为客户提供类库，就可以参考 OpenJDK 的文件组织方式。

如果没有什么现成的项目可以参考借鉴的，请记住以下两点：

文件的组织要一目了然，越直观，越有效率；

可维护性要优先考虑。这要求文件组织要层次分明，合理区隔、照应、使用不同的空间。

一起来动手

由于项目的多样性，项目文件组织的具体形式有很多差异。借这个机会，我也想学习一下大家的项目文件组织经验。你做的项目，是怎么组织文件的？为什么选择这种组织的形式？你最欣赏的这种形式的哪几点？你阅读一个项目代码时，是怎么一步一步深入进去的？欢迎你把你的经验公布在讨论区，我们一起来学习，一起进步。

欢迎你把这篇文章分享给你的朋友或者同事，一起来探讨吧！

13 | 接口规范，是协作的合约

一个软件项目，一般需要交付两类文档。一类文档是面向开发者的，另一类文档是面向最终用户的。这两类文档，由于面向用户的不同，无论是内容还是形式，都有巨大的差异。今天我们先来聊聊面向开发者的文档。下一讲中，我们再接着聊面向最终用户的文档。

区分外部接口和内部实现

为了便于维护和协作，一个软件通常被划分为几个不同的部分。比如我们通常使用的 MVC 架构，把软件分为模型（Model）、视图（View）和控制器（Controller）三个部分。这样做，可以降低复杂度，让程序结构更加直观。同时，这种架构也很容易对程序进行修改和扩展，并且可以重复利用基础的功能。

不同功能的分离，让程序员之间产生了分工，专业人员可以更聚焦于个人的专长领域。这是一个多赢的局面，也能让软件的质量得到提升。

既然有分工，就要有协作。MVC 架构把软件拆分为三块，是分工；而 MVC 模块之间的调用关系，就是协作。

一个好的软件设计，要区分外部接口和内部实现。外部接口，就是协作的界面，要简单规矩；内部实现，可以是千变万化的复杂小世界。

这种区分无处不在，即使是最普通的 API。比如我们常用的 InputStream，一旦我们获得这个对象实例，就可以调用它的 read() 方法。 我们不用去关心，它的底层实现是一个文件，一段内存，还是一个远程连接。InputStream 的接口定义只有十个方法，短短的 500 多行代码。

但是它的内部实现却是一个更大的世界，广泛地分布在不同的类库、不同的模块，实现着不同的具体功能，有些实现甚至超出想象的复杂，比如一个安全连接的 _InputStream_ 的实现，一般有着数万行的代码。

幸运的是，我们区分了接口和实现，调用者就不用去关心这些复杂的实现了，只需要理解接口规范就好。

提高协作效率的最高技巧不是提高沟通技巧，而是要减少沟通的数量，提高沟通的质量，尤其是要减少数量。如果你参加了工作，没完没了的会议，没完没了的文案，都会加深你对这条原则的理解。软件的设计也是这样，外部接口，要少、要小、要描述清楚。

接口规范是协作合约

由于外部接口是协作的界面，是调用者和实现者之间的合约，所以对它就有了更加严格的要求。这里我总结了合约的四个原则：成文、清楚、稳定、变更要谨慎。

具体要怎么实践这些原则呢？

合约要成文

无论对于调用者，还是实现者来说，外部接口的使用都要有章可循，有规可依。如果调用者需要去看实现代码来理解外部接口，那么外部接口和内部实现的分离还有什么用呢？不就背离了外部接口和内部实现分离的初衷吗？这样做既是对实现者的纵容，也是对调用者的无视。

比如说，Java 的每个版本的 API 文档和指南，就是 Java 语言的合约。

合约要清楚

合约既然是我们协作的依靠，就一定要清晰可靠、容易遵循，不能有模棱两可的地方。如果接口规范描述不清，既误导调用者，也误导实现者。

如果接口规范复杂难懂，说明接口的设计也很糟糕。

那么接口规范要怎么描述呢？

接口规范主要用来描述接口的设计和功能，包括确认边界条件、指定参数范围以及描述极端状况。比如，参数错了会出什么错误？

这里需要注意的是，接口规范不是我们定义术语、交代概念、提供示例的地方。这些应该在其他文档中解决，比如我们下次要聊的面向最终用户的文档。

合约要稳定

既然是合约，意味着调用者必须依赖于现有的规范。比如 InputStream.read() 这个方法，接口规范描述的是读取一个字节（8-bit），返回值是介于 0 和 255 之间的一个整数。如果我们要把这一个规范改成返回值是介于 -128 到 127 之间的一个整数，或者是读取一个字符（比如一个汉字），都会对现有的使用代码造成灾难性的影响。

接口的设计和规范的制定，一定要谨慎再谨慎，小心再小心，反复推敲，反复精简。一旦接口合约制定，公布，然后投入使用，就尽最大努力保持它的稳定，即使这个接口或者合约存在很多不足。

变更要谨慎

世界上哪里有一成不变的东西呢！技术的进步、需求的演进，总是推着我们朝前走。合约也需要跟得上变化。

可是，接口合约毕竟不是租房合约，可以一年一续，每年变更一次。租房合约的变更成本很小，但软件的接口合约变更的影响要严重得多。特别是兼容性问题，稍微一丁点儿的接口规范变化，都可能导致大面积的应用崩溃。越成功的接口，使用者越多，变更的影响也就越大，变更的成本也就变高，变更也就越困难。你可以试着想一想，如果 InputStream.read() 这个方法在 Java 中删除，会造成多大的影响？会有多少应用瘫痪？

所以，对于接口规范，我们的原则是，能不变更就不变更；必须的变更，一定要反复思量该怎么做才能把影响降到最低。

使用 Java Doc

说完了接口规范的几个原则，我们就来讲一下，如何实践这些原则。接口的规范描述，应该怎么组织？

从使用者角度出发，包括接口的调用者和实现者，接口的规范应该便于阅读，便于查找。从制定者的角度出发，接口的规范应该便于定义，便于维护。

JavaDoc 就是一种顾及了多方利益的一种组织形式。它通过文档注释的形式，在接口声明的源代码定义和描述接口规范。这种和源代码结合的方式，可以方便我们维护接口规范，也有利于保持接口规范和接口声明的一致性。

JavaDoc 工具可以把文档注释，转换为便于阅读为 HTML 文档。这样就方便规范的使用者阅读了。

当然，也不是所有的规范，都一定要使用 JavaDoc 的形式，特别是冗长的规范。如果有两种以上不同形式的规范组织文档，我建议一定要互相链接、引用。比如，冗长的规范可以单独放在一个文件里。然后，在 Java Doc 对应的文件里，加上改规范的链接。

比如下面的例子中，“Java Security Standard Algorithm Names Specification”就是一个独立的，较长的规范文档。当需要使用这个文档的时候，就要在对应的接口中指明该文档的位置，这样方便用户进行检索。

上面的文档注释，经过 JavaDoc 的处理，就变成了便于用户阅读的文字。

protected Signature(String algorithm)

Creates a Signature object for the specified algorithm.

Parameters:
algorithm - the standard string name of the algorithm. See the Signature section in the Java Security Standard Algorithm Names Specification for information about standard algorithm names.

谁来制定接口合约？

这本来不是一个问题。但是由于我们选择在源代码中，需要通过文档注释表达接口合约，这就成了一个很严肃的问题。

源代码的维护者，是不是对接口合约拥有无限的修改权利呢？

肯定不是的。

既然是合约，就是大家都认可并且接受的规范和细节，只有形成共识才能编辑和修订。合约的编写和修订，一般不应该由源代码的维护者一人决定，而应该由参与各方充分沟通和协商。

“三个臭皮匠，顶个诸葛亮”，我们要充分尊重参与各方的能力，信任充分的沟通可以成就更好的规范。

一个软件项目，不管大小，只要参与者超过两个，都要讨论清楚彼此之间的分工协作方式。这当然也包括，讨论清楚如何制定、修改程序接口。

比如，OpenJDK 的接口制定和修订，就一定要经过下面的步骤：

起草接口规范，或者起草提议的修订规范；

找相关领域的专家，审议草案，并根据评审意见，修改接口规范；

如果领域专家审议通过，提交兼容性和规范性审查程序； 并根据审查意见，相应地修改接口规范；

兼容性和规范性审查通过，修改接口合约；

按照议定的接口规范，编写最终的实现的代码。

当然了，你的软件项目，也许和 OpenJDK 有巨大的差异。你要找到适合自己公司和项目的，接口合约制定和修改的适当方式。

小结

对于接口规范，我们要有意识地使用下面的这条原则：

接口规范是使用者和实现者之间的合约。

我们在工作过程中，如果有和接口相关的迷惑或者争执，可以多想一想上面的这条原则。

一起来动手

2018 年 12 月 25 日，部分开发者突然发现他们开发的 Web 网页的界面发生了变化，按钮上方出现“积雪”。这超出开发者的脑洞和认知，难道是圣诞老人的礼物，或者是黑客的祝福？经过探索发现这是前端 UI 组件库 Ant Design（简称 antd）提前埋入一个未经声明的“彩蛋”。事件迅速发酵，引起了巨大争议。

前人的危机都是后人的财富。该怎么做，才可以避免类似的事情？欢迎你在讨论区留言，我们一起把这个事件转化成我们的见识和能力。

也欢迎点击“请朋友读”，把这篇文章分享给你的朋友或者同事，一起来交流。

14 | 怎么写好用户指南？

前一段时间，我要买一部家用的跑步机。有一款跑步机看起来配置齐备，商品的标题中指明“需要组装”。

商品的评论只有两条。其中一条给了三分：“还没有来得及试一试这个新到的跑步机。因为，我一直试着把它组装起来。我做梦都没有想到，‘需要组装’意味着我花了三天时间，都没有组装起来。它也许是一个好的跑步机，可是令人失望的是，这些零件到底该怎么凑在一起！”

而另一条则给了最低的一分。评论写道：“商品描述不准确。这台机器非常重，长度甚至超过两人沙发。一般的家庭根本放不下这台跑步机。已经退货了”。

你可以想象，这两条仅有的评论对这款跑步机的销售有多大的杀伤力。它本身的品质无论如何，都不至于沦落到一分、三分的地步。

问题在哪儿呢？无论是谁，花了三天时间都搞不定组装，肯定有一肚子的不满意。好不容易组装起来，却发现没有空间放置，又要拆掉退货，就会更不满意。

我了解了一下这款跑步机的用户手册，发现组装非常繁琐，所涉及的部件有很多，还真不是一下子就可以搞定的。

很显然，用户指南非常重要，但这款跑步机却给我们提供了一个反面的案例，可见写出一份好的用户指南也不是一件容易的事。

最好的用户指南，是产品本身。我们随手拿一只圆珠笔，就知道怎么用。然而，不是所有的产品都能够简单到拿来就用。一份合格的用户指南，要帮助用户减少产品使用的障碍，快速地使用产品。

作为程序员，我们常和软件的用户指南打交道。软件和的用户指南和家用电器的操作指南一样，是最终用户的参考手册。今天，我就和你聊聊怎么写好软件的用户指南这个话题。

谁是指南的用户？（用户指南写给谁？）

既然是用户指南，就必须让用户看得懂、用得上。首先我们需要搞懂，谁是用户？ 只有这样，我们才能了解用户的日常习惯和知识偏差。

一般来说，我们把用户的门槛降得越低，潜在用户的群体就越大，软件就越容易推广。

比如说，访问一个网站时，我们只要在浏览器输入网址，就可以浏览网站的内容。这背后的技术，真的像大海一样深不可测。可是，作为用户，我一点儿也不关心这些技术，我只关心，我能不能访问到这个网站。

假设一个浏览器的设计者，添加了两个强制的选择项，让用户访问网站时选择：

使用 TCP 协议还是 UDP 协议？

使用 HTTP 还是 HTTPS?

估计很多用户立即就会不知所措。他们大部分都不知道这些协议到底是什么，更别提让他们做出有实际意义的选择了。

我举这样的一个例子，想说明的是，有了用户的千差万别，才有了软件的千差万别。我们不能想当然地认为，有了详实的用户指南，用户就能够使用产品。

事实上，用户指南，不能超越用户的理解能力和操作能力。

什么时候确定产品用户？

这是一个老生常谈的问题。之所以常谈，是因为我们很容易就忘了我们的用户。所以，不得不经常拎出来谈一谈，时不时地拽一拽这根弦。

等到产品出来的时候才确定用户，再写用户指南，就太晚了。 谁是我们的用户？用户该怎么使用我们的产品？这些问题应该是产品还没有影子的时候，就要开始着手解决的问题。 然后，在产品研发的全工程中，反复地调试、测试这两个问题的答案。

需要注意的是，这两个问题答案，千万不要越来越丰富，而是要简化再简化，越来越简单。

无论用户手册写得有多好，一个需要组装近百个部件才可以使用的跑步机，都瑜不掩瑕。

所以在产品设计时，就需要解决掉用户定位和怎么使用这两个问题。

比如浏览器的设计，我们一定要压制住给用户提供更多选择的冲动，把技术细节隐藏到具体实现中去，而不是让用户来做选择。即便是简单的给浏览器输入地址这一行，你想一想下面的这些地址隐藏了多少细节！

我们经常使用浏览器，所以讨论浏览器的设计时，可能比较容易理解简单带来的巨大好处。如果换成我们自己的软件产品，看起来就没有那么容易了。

我们可能生怕遗漏了什么、错过了什么。丢掉东西，尤其是放弃一个看似美妙的想法，不符合我们的思维方式和行为习惯。 但在用户指南这个问题上，我们需要记住“less is more”。如果可以从用户的角度看问题，产品中自我的影子就会少一点点，这些选择也会简单一点点。

保持指南和代码的一致

和接口规范一样，用户指南一般也是由程序员和其他的作者一同完成的。和接口规范不一样的是，接口规范可以在源代码文件中定义、描述，而用户指南通常是独立于源代码的文档。这种分离的组织形式，经常会让用户指南和源代码脱节。如果维护指南的作者和维护源代码的作者不是同一个人，脱节就会更加严重，导致维护和服务都很困难。

那么该怎么办呢？程序员一定要和用户指南的维护者保持通畅的沟通，商定协作的方式。

比如，在 OpenJDK 中，如果源代码的修改导致了行为和接口的改变，就要考虑是不是需要修改用户指南。如果需要修改用户指南，就要提交一个用户指南修改的提议。在这个提议里，说明代码导致的行为、接口的变更，以及用户指南修改的建议。用户指南的维护者收到提议后，就会跟踪代码的变更，提议具体的修改方案，完成用户指南的修改。源代码修改和用户指南的修改，一定要放在一个连贯的流程里。

保持用户指南和源代码的一致性是一个很有挑战性的任务。这是一个成本很高的问题，你一定要找到适合自己的办法。比如可以像 OpenJDK 一样，通过改进开发流程，把用户指南修改和代码变更绑定在一起。用户指南修改修改完成，这个代码变更才算完成。如果工具使用得当，团队合作流畅，这是一个可以尝试的好办法。

开发指南的实践

开发指南是一类我们经常接触的用户指南，它的典型用户是使用该软件 API 的软件工程师。

我们上一次聊到了接口规范。开发指南和接口规范这两类文档是怎么分工的呢？ 接口规范的重点在于接口的设计和功能描述上，包括确认边界条件、指定参数范围以及描述极端状况。而开发指南的重点在于定义术语、交代概念、提供示例、问题排查等。

接口规范描述的是每一个部件的规格，是一个细节、局部的范围。开发指南说的就是怎么理解这些部件之间的关系，怎么把这些部件组合起来，形成更大的部件或者产品。

对于一个陌生的类库，我们一般要先阅读开发指南，然后检索接口和接口规范。如果开发指南让用户抓狂，你可以回顾一下开头讲到的跑步机的例子，想象下影响会有多糟糕！

那么合格的开发指南都要符合哪几个规则呢？我总结为三点：需要交代清楚概念，可以快速上手，示例都可操作。

交代概念

一个合格的开发指南，不要假定用户具有和开发者一样的知识范围。对应的接口规范和开发指南里涉及到的概念，一定要交代清楚。我们可以假定一个程序员了解 IP 地址这个概念，这是计算机入门的基本概念。但是，不要假定他了解 IP 地址的计算方式，虽然也是基础知识，但是大部分人记不住知识的细节。

所以说，交代清楚概念，很方便作者和读者之间建立共识，降低后续文档的阅读负担。

快速上手

一个好的开发指南，要尽最大可能，让开发者快速上手。

我们学习一门编程语言，往往从“Hello, World!”这个例子开始。它本身并没有太多玄妙的东西，但可以让一个初学者最快地玩耍起来，然后，再逐步探索更深入的内容。

这是一个值得学习的方法。很多开发指南，都有一个类似于“Hello, World!”这样的简短的快速入门章节。你也可以试试这个办法。

但需要注意的是，快速入门的章节一定要简单、靠前。让读者最快接触到，很容易学会，方便“玩耍”。

示例都可操作

可操作性是开发指南的一个命门。所有成文的方法和示例，都要求可以使用、可以操作、可以验证。虽然说起来简单，但是做到这一点并不简单。

开发指南，需要适应版本的变迁和代码的修改。第一版的开发指南也许看起来、用起来都很美妙，但到了第二版，就会面临代码和指南脱节的问题。

指南的方法是否还适用？示例的代码是否需要修改？示例的输出有没有变化？问题排查的办法有没有变更？ 这些都是潜在的问题。

如果我们在开发指南里使用了不能执行的伪代码，这些问题就会更加突出。我们虽然清楚第一版伪代码的逻辑，却没有办法验证这样的逻辑在第二版是不是依然成立。

如果开发指南里的示例都可以被编译、被使用，那么解决起来就会轻松很多。修订开发指南时，如果我们把这些示例重新编译、运行、查看结果，然后对比文档，就可以解决掉很多潜在的忧患。

当然，可操作的开发指南，用户不仅仅是可以阅读，也可以照着例子玩耍。

小结

最后我来总结一下，用户指南的形式和组织方式各式各样，我们没有办法去限定它的具体格式。 一般而言，我们要建立下面的意识：

从用户的角度出发来思考用户指南，用户指南要容易上手；

用户指南和源代码一样，也有开发周期，也是需要维护的。

一起来动手

今天，我们找两个真实的例子练练手。限于版权，我不能拷贝这两个例子的内容。麻烦大家去看原文。

第一个例子是腾讯微信的《小程序开发指南》第一小节的"Hello World"示例。第二个例子是GitHub 页面开发的入门网页，也是一个 Hello World 示例。你仔细研究下，看看是不是都容易上手？每一个例子都有什么优点，有什么缺点？ 你有哪些改进的建议？

欢迎在留言区分享你的看法，我们一起来讨论、学习。

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

15 | 编写规范代码的检查清单

通过前面十几讲的学习，我们已经把代码“规范”篇的内容学习完了。今天，我们一起把前面讨论到的观点总结一下，并探索一下编写规范代码时的最佳实践检查清单。一份有效的检查清单，可以帮助我们记忆、遵循和执行代码的一系列规范。

标准与指南

在讨论编码规范之前，我们首先要认识什么是标准，什么是指南，以及它们各自的局限性。这样我们就能知道，什么可以做，什么不可以做。

标准是既定的做事方式，它定义了一个事物如何始终如一地达到同样水准的细节。标准的特征在于，它既是必需的，也是强制的。

然而，在现实实践中，能够标准化的东西是有限的。所以就有了指南，它是标准的补充。

指南告诉我们应该采取的总体方向和最佳实践，它是一个指导原则，是建议，不强制执行。用户可以根据具体情况，决定是否遵循相关的条款。

我们所说的编码规范，实际上通常是指导原则。虽然不具备强制性，但我们也有必要区分对不同建议条款的态度，比如对使用“强烈推荐”这样字眼的建议来说，我们就应该格外重视。这样，可以避免不必要的争执，降低复杂性。

为什么需要编码规范？

1. 提高编码的效率

在不损害代码质量的前提下，效率可以节省我们的时间和成本。这种节省不仅仅停留在编码阶段，更体现在整个软件的生命周期里。我在第四篇已经详细解释了这一点。

2. 提高编码的质量

代码的质量在于它和预期规范的一致性。一致、简单、规范的代码易于测试。相反，复杂的代码会加大测试的难度，难以达到合适的测试覆盖率。另外，代码的复用也意味着质量的复用，所以代码的问题会随着它的复用成倍地叠加，提高了软件的使用或者维护成本。

3. 降低维护的成本

代码的维护要求代码必须能够修改，增加新功能，修复已知的问题。如果代码结构的清晰、易于阅读理解，那么问题就容易排查和定位。

4. 扩大代码的影响

要想让更多的人参与，就需要一致的编码风格，恰当地使用文档。要方便他们阅读，便于解释。

使用编码规范可以让一个程序员减少出错，避免不必要的困扰。另外，编写规范的代码也是一种专业、成熟的体现。

编码规范的心理因素

编码风格最原始的依赖因素是人的行为习惯和心理学基础。通过了解一些基本的心理学原理，我们可以更好地理解编码风格的的基本规则。

两种思维模式

我们有两种思维模式，自主模式（快系统）和控制模式（慢系统）。自主模式的运行是无意识的、快速的、不怎么耗费脑力；控制模式需要集中注意力，耗费脑力，判断缓慢，如果注意力分散，思考就会中断。

自主模式在熟悉的环境中是精确的，所作出的短期预测是准确的，遇到挑战时，会第一时间做出反应。然而，它存在成见，容易把复杂问题简单化，在很多特定的情况下，容易犯系统性的错误。比如说，第一印象、以貌取人，就是自主模式遗留的问题。

当自主模式遇到麻烦时，控制模式就会出面解决，控制模式能够解决更复杂的问题。但刻意掌控会损耗大脑能量，而且很辛苦。处于控制模式中太长时间，人会很疲惫，丧失一部分动力，也就不愿意去做启动控制模式了。比如，很多人害怕数学，多是因为控制模式确实很吃力。

自主模式和控制模式的分工合作是高效的，损耗最小，效果最好。快速的、习惯性的决断交给勤快省力的自主模式，复杂的、意外的决断由耗时耗力的控制模式接管。

编码规范中很大一部分内容，是增加共识、减少意外，扩大自主思维模式覆盖的范围，减少控制模式必须参与的内容。熟练掌握编码规范可以逐渐让这一套规则存在于人们的下意识中，这样编码的时候就像我们使用筷子一样，简单又自然。

识别模式

我们能够在这个世界上存活下来，依靠的不是识别完整的情景，也不是从头开始分析每一个情景，而是通过既定的可识别模式，来匹配这个世界。正常期望以外的模式，常常会让我们感到吃惊和困惑，甚至是生气。

模式识别的认知是一柄双刃剑，既是福音也是祸害。它可以帮助我们毫不费力地使用经验，但习惯一旦养成就很难改变，我们不愿意打破旧模式，去学习新模式和接受新技术。

程序员很容易理解和自己编码风格类似的代码。如果编码风格和自己的大相径庭，就会感到焦躁和烦恼。编码风格一旦形成，就难以更改，转变很痛苦。幸运的是，一旦努力转换成新习惯，我们就会喜欢上新的编码风格。

一份好的编码规范，刚开始接受会有些困难。我们甚至会找很多借口去拒绝。但是，一旦接受下来，我们就成功地改进了我们的识别模式。

猜测模式

对于既定模式的识别，是通过猜测进行的。对于每一个新场景，大脑立即会把它起始部分当作一个线索，然后通过拟合所有已知模式的起始部分，来预测模式的其余部分，猜测“言外之意”。我们掌握的信息越少，就越有可能犯错误。比如，在医院看到穿白大褂的，我们默认他们是医护人员。但医护人员的判断标准并不是白大褂。

所以在编写代码时，我们要有意识地提供足够的线索和背景，使用清晰的结构，加快模式的识别，避免造成模式匹配过程中的模糊和混淆带来的理解障碍。

记忆模式

我们的记忆模式有四种，包括感官、短期、工作和长期记忆。

感官记忆是对我们感官体验的记忆，非常短暂（大约三秒钟），比如我们刚刚看到的和听到的。

短期记忆是我们可以回忆的，刚刚接触到的信息的短暂记忆。短期记忆很快，但是很不稳定，并且容量有限。如果中途分心，即便只是片刻，我们也容易忘记短期记忆的内容。

工作记忆是我们在处理认知任务时，对信息进行短暂存贮并且执行操作的记忆。工作记忆将短期记忆和长期记忆结合起来，处理想法和计划，帮助我们做出决策。

长期记忆涵盖的记忆范围从几天到几十年不等。为了成功学习，信息必须从感官或短期记忆转移到长期记忆中。和短期记忆相比，长期记忆记忆缓慢，但是保持长久，并且具有近乎无限的容量。

我们在组织代码时，不要让短期记忆超载，要使用短小的信息快，方便阅读；要适当分割需要长期记忆和短期记忆的内容，比如接口规范和代码实现，帮助读者在工作记忆和长期记忆中组织和归档信息。

眼睛的运动

当我们看一样东西的时候，我们不是一下子就能看清它的全貌。事实上，我们的眼睛一次只能专注于一个很小的区域，忽视该区域以外的内容。当然，我们可以意识到还有更大的区域，然后快速跳转到其他区域。

有时候，我们需要反复研读一段代码。如果这段代码可以在一个页面显示，我们的眼睛就很容易反复移动，寻找需要聚焦的目标。如果这段代码跨几个页面，阅读分析就要费力得多。

当我们阅读时，我们的眼睛习惯从左到右，从上到下移动，所以靠左的信息更容易被接受，而靠右的信息更容易被忽略。

但是，当我们快速阅读或者浏览特定内容时（比如搜索特定变量），眼睛就会只喜欢上下移动，迅速跳过。聚焦区域小，眼睛倾向于上下移动，这就是报纸版面使用窄的版面分割，而不是整幅页面的原因之一。

在编码排版时，要清晰分块，保持布局明朗，限制每行的长度，这样可以方便眼睛的聚焦和浏览。

编码规范的检查清单

下面的这个清单，是我看代码的时候，通常会使用的检查点。如果有检查点没有通过，阅读代码的时候，就要格外留意；编写代码的时候，还要想想有没有改进空间；评审代码的时候，要问清楚为什么这么做，给出改进的建议。

你也可以参考一下。

代码是按照编码指南编写的吗？

代码能够按照预期工作吗？

文件是不是在合适的位置？

支撑文档是不是充分？

代码是不是易于阅读、易于理解？

代码是不是易于测试和调试？

有没有充分的测试，覆盖关键的逻辑和负面清单？

名字是否遵守命名规范？

名字是不是拼写正确、简单易懂？

名字是不是有准确的意义？

代码的分块是否恰当？

代码的缩进是否清晰、整洁？

有没有代码超出了每行字数的限制？

代码的换行有没有引起混淆？

每一行代码是不是只有一个行为？

变量的声明是不是容易检索和识别？

变量的初始化有没有遗漏？

括号的使用是不是一致、清晰？

源代码的组织结构是不是一致？

版权信息的日期有没有变更成最近修改日期？

限定词的使用是不是遵循既定的顺序？

有没有注释掉的代码？

有没有执行不到的代码？

有没有可以复用的冗余代码？

复杂的表达式能不能拆解成简单的代码块？

代码有没有充分的注释？

注释是不是准确、必要、清晰？

不同类型的注释内容，注释的风格是不是统一？

有没有使用废弃的接口？

能不能替换掉废弃的接口？

不再推荐使用的接口，是否可以今早废弃？

继承的方法，有没有使用 Override 注解？

有没有使用异常机制处理正常的业务逻辑？

异常类的使用是不是准确？

异常的描述是不是清晰？

是不是需要转换异常的场景？

转换异常场景，是不是需要保留原异常信息？

有没有不应该被吞噬的异常？

外部接口和内部实现有没有区分隔离？

接口规范描述是不是准确、清晰？

接口规范有没有描述返回值？

接口规范有没有描述运行时异常？

接口规范有没有描述检查型异常？

接口规范有没有描述指定参数范围？

接口规范有没有描述边界条件？

接口规范有没有描述极端状况？

接口规范的起草或者变更有没有通过审阅？

接口规范需不需要标明起始版本号？

产品设计是不是方便用户使用？

用户指南能不能快速上手？

用户指南的示例是不是可操作？

用户指南和软件代码是不是保持一致？

小结

虽然说编码规范不是强制性的标准，但是如果你能尽可能遵守相同的规范，就会让工作更简单、更高效。

需要特别强调的是，认为写好代码只有一种方法是愚蠢的。虽然编码规范的指导原则是通用的，但是其中的具体细节则依赖于具体的环境，因具体的需求而变化。所以，除了遵循编码规范外，你还要做好随时重审、调整编码规范的准备，保持编码规范的活力，跟得上实际情况的变化。

希望你根据自己的实际情况，不断完善、丰富上面的清单，使这份清单更直观、更容易遵循，保持长久的活力，让它更适合你自己。

一起来动手

正像我们讨论到的，不同的场景，检查清单也不一定相同。如果让你列一个你自己实际工作中需要的编码规范检查清单，会是什么样子的？ 你可以在我上面的清单上加减检查点，或者新做一个列表。欢迎在留言区公布你的检查清单，我们一起来讨论、学习。

另外，推荐一本书《清单革命》。清单能够起到的作用，常常被忽视。这本书告诉我们清单这个小东西，能给我们的工作带来多么巨大的帮助。

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

16丨代码“规范”篇用户答疑

更到这一篇的时候，意味着专栏第一模块“代码规范”篇已经更新完毕了。在更新的时候，非常感谢同学的积极踊跃地留言，提出了很多独到的见解，也给专栏增色了许多。

今天，我来解答一下留言里的一些疑问。其实有很多问题，我们已经在留言区里讨论了。这里，我们就挑选一些还没有解决掉的问题，深入讨论一下。

@大於一：回归测试其实怎么测? 不懂。

答：InfoQ 有一篇文章《回归测试策略概览》我觉得写得不错。你可以找来看看。

我想要补充的一点是，要想发挥回归测试的最大作用，要把回归测试自动化。只需要简单的操作，就可以启动回归测试。比如使用“make test”命令行，或者其他集成工具的触发配置。这样，我们做的每一个更改，哪怕只是修改了一行代码，都可以跑一遍回归测试。

@一一：高质量的代码，依赖于高质量的流水线，那么问题来了，如何建立中小企业的高质量的代码流水线呢？

答：回答这个问题之前，我们先看看一些公开的数据。

Java SE 的安全，是由 OpenJDK 的 Security 组负责的，评审一般通过 Security-Dev@Openjdk.java.net 的邮件列表进行。根据 OpenJDK 的登记数据，Security 组总共有过 13 人，其中包括离职的，退休的。现存的团队，也就八九个人的样子。

这八九个人要做多少东西呢？下面是一个不完全的简要的列表：

Java 平台安全（platform security, Java language）

密码学 （Cryptography，JCA）

认证和授权（Authentication and Access Control，JAAS）

安全通信（Secure Communications，JSSE/JGSS/SASL）

公开密钥基础设施（Public Key Infrastructure (PKI)）

其中任何一个模块，如果没有多年的经验积累，都很难做好。如果你观察 Security-Dev 邮件列表，就会注意到每一个模块的话题，参与活跃的也就两三人。这就是说，如果把一个模块看作一个虚拟的团队，这个团队也就两三人而已。

我想通过这个例子告诉你，大公司的研发团队，也可以是一个小团队；团队里的成员，也不是什么都懂的。

这样的团队和中小企业的研发团队有什么区别呢？我觉得数量上的区别可能不是很大，两三人的团队，八九人的团队，中小企业的团队规模大抵也是这样的，甚至更大。质量上的差别呢？我觉得这没有可比性，你的团队不一定需要密码学的专家。能够满足产品目标的团队就是一个质量好的团队。

我们来看看我们在专栏里提到的流水线，有哪一道关卡是中小企业做不了的？其实都做得了，但是没有养成这么做事的习惯。

首先，程序员是最重要的关卡，决定着整个流水线的运转效率。专栏里有一篇文章，我们讨论了优秀的程序员是什么样的。我们要做的就是，让自己成为这样的程序员，找到这样的程序员做同事，帮助同事成为这样的程序员。建立这样的氛围确实不容易。比如，我们讨论的编写代码允许犯错误，允许反复地犯错误，这一点很多中小企业很难做到。杀一个程序员祭天，简单粗暴，还能发泄愤怒，大家都喜欢这样的方式。有时候，要求程序员有责任心，有主人翁意识，要知错就改，就是一种看起来正确无比，其实没有什么意义的做法。

在编译器这道关中，其实我们大都使用相同的编译器。区别可能在于我们对于编译器警告的处理态度。我们可以忽视编译器警告，也可以认真分析每一个编译器警告，甚至消除每一个编译器警告。这样做，谁不会花费很多时间呢？刚开始，一个程序员写的代码，可能有很多警告，然后他试图弄清楚这些警告，消除掉这些警告。通过这个过程，他成为一个更好的程序员，下一次写代码，就不会出现这么多警告了。随着他越来越清楚警告的来源和解决办法，他的程序越来越好，花的时间越来越少。如果他把所有的警告都忽视掉，无论多长时间，他都掌握不了解决这些警告的方法，无法保证代码质量。我们小时候，算 1 加 2 等于几，都要掰掰手指头。长大后，这样的计算，根本都不需要思考。每个程序员，都有掰着手指头学习的阶段。这一点，我觉得大企业和小企业，没有太大的区别。

回归测试这道关，其实就是把研发流程中的测试，规范化出来。我们写程序，都要测试，都要有测试代码。把测试代码独立出来，形成测试案例，然后每次代码变更时，把所有测试案例都跑一遍，就是回归测试了。如果回归测试通不过，就说明代码变更可能有潜在的问题，需要修改。这里面的难点，就是要坚持写测试代码。这些代码，测试人员要写，研发人员也要写。如果写测试代码是一个硬条件的话，一个公司就不能寄希望于程序员的责任心这种幻象。更有效的做法是，如果一个变更没有测试代码，就不允许提交代码。如果允许意外，程序员必须清楚地解释为什么没有测试代码。比起写个测试代码，解释起来大都是很费劲的。这个小制度的安排，就可以帮助我们养成写测试代码的好习惯。习惯了之后，我们很快就会发现，写测试代码，使用回归测试，其实是帮助我们节省时间、减少错误的。这时候，我们一般就会喜欢上写测试代码了。这一点，我觉得大企业和小企业，没有太大的区别。

代码评审这道关，其实是误解最深的一道关。第一个误解是，公司没有牛人，没办法评审。其实，评审做重要的，就是多几双眼睛看着，防范我们个人难以避免的错误。不是说做评审的，就一定要比被评审的专业、见识多。即便是刚毕业的大学生，也能够看到我们自己无法完全克服的错误，比如说我们在文章里提到的"goto fail"错误。在 OpenJDK 社区，谁都可以评审，谁都可以发表不同的意见，提出改进的建议。如果评审依赖牛人，牛人就是最大的瓶颈。第二个误解是，代码评审太浪费时间，耽误功夫。代码评审，确实需要付出时间。可是这只是表象，代码评审可以减少错误，提高代码质量，减少代码返工，帮助积累经验，这些都是节省时间的。如果我们看的角度稍微大一点，就会有不同的结论。这一关的难点，就是要坚持代码评审。这同样不能依赖于我们脆弱的责任心和主人翁精神，而要依靠小制度的安排。比如没有经过评审的代码，就不能提交代码。这一点，我觉得大企业和小企业，没有太大的区别。

代码分析这道关，其实相对来说，比较简单。找到相关的工具，坚持定期检测，检查分析结果就行了。没什么玄妙的东西。这一点，我觉得大企业和小企业，也没有太大的区别。

我们该怎么把这个流水线搭建起来呢？我认为最重要的就是要启动代码评审这个环节。这个环节启动了，其他的环节，顺势就建立起来了。这个环节没有，其他环节也不能很好地执行。

使用 Git 或者 Mercurial 这样成熟的版本控制工具，以及像 Bugzilla，Phabricator，Jira 这样的 bug 管理工具。

找一个工具，可以生成可视化的代码变更页面。比如 OpenJDK 的 webrev，或者像 Phabricator 这样的集成工具。

找到一个集中的地方，可以让所有人都看到代码变更页面，都可以方便地提意见。比如，OpenJDK 使用 cr.openjdk.java.net 展示，使用邮件列表讨论。GitHub 和 Phabricator 这样的集成工具也有类似展示窗口。

制定小制度，没有经过评审的代码，不能提交。OpenJDK 的提交，是用过检查 Reviewed-by 这样的提交描述字段，来自动判断的。Phabricator 这样的集成工具，也有类似的强制设置。

制定代码评审的通过准则，比如不规范的代码，没有通过测试的代码，以及没有测试代码的变更，不能提交。如果允许例外，提交者要解释清楚。

把测试归拢起来，找一个自动化的工具，可以执行回顾测试。比如说使用“make test”命令行，就可以执行所有的回归测试。类似 Phabricator 这样的集成工具，也有类似的测试执行功能。

定期执行代码分析，并且把分析结果记录下来，检查、分析、改进。这个分析工具可以是 SpotBugs，或者其他的商业软件。

把需求分析、架构设计、接口设计，也当作 bug 管理，纳入到评审的过程中来。

改进激励标准。程序员的评价标准不要狭隘在编写了多少行代码上，还要看看参与了多少评审，发现了多少问题。

鼓励内部推广软件开发经验，比如说什么样的插件可以编写规范的代码，什么样的代码容易出现安全问题，什么样的代码效率比较高。有了成熟的经验后，就总结下来，推广出去，形成团队的重要共识和财富。

这些工具用起来，各个流程衔接起来，就是一个可以运转的流水线了。随着大家越来越习惯这样的工作方式，流水线的效率就会越来越高。而且流水线本身就是一个好老师，可以促进交流，加快每个参与者的成长。当然，从无到有，去适应这个流水线需要一段时间。

如果对比大公司和小公司，也许有没有现成的流程，算是区别吧。由于丰富的开源软件以及云服务，工具本身的区别，其实很小了。

拎着一挺 AK47 去战斗，总不如赤手空拳、手抓子弹来得陶醉，更别提使用飞机大炮了。我们尽可以耻笑电视里的画面，“拿着手枪，算什么英雄！” 但是轮到我们自己去战斗时，最好有飞机大炮航空母舰。

@轻歌赋：想问问老师，如何在身边没有其他评审的情况下，提供一些自己检查代码逻辑 bug 的方法呢？而且对业务分析不熟悉，经常会出现建表少了某个字段的情况，请问老师有没有什么相对系统化的设计方面的知识可以学习呢？

答：对于业务分析，你可以参考下使用 UML 或者思维导图这样的工具。画画图，有助于我们加深对业务的理解，找出业务的逻辑甚至问题。我自己做需求分析的入门书籍，是《软件工程》（Roger S. Pressman 或者 Ian Sommerville 的，我忘记自己当初学的是那一本了）和《软件需求》（Karl E. Wiegers）。

我们应该都过了面向对象设计的门槛了，有三本书，可以让我们接触优秀的设计理念和最佳时间。一本是《Unix 程序设计艺术》（Eric S. Raymond）。另一本是《设计模式》（Erich Gamma， Richard Helm, Ralph Johnson, John Vlissides）。学设计模式，千万不要固化了思维，所以我建议一定要仔细阅读每一个设计模式的“意图”“动机”“适用性”这几方面的内容。另外一本书，就是《Effective Java》（Joshua Bloch），Java 程序员应该人手一本，像是使用字典一样使用这本书，先看一遍，然后时刻备查。

以上就是答疑篇的内容。从下一篇文章起，我们就要开始“代码经济篇”的学习了。如果你觉得这篇文章对你有所帮助，欢迎点击“请朋友读”，把这篇文章分享给你的朋友或者同事。

17 | 为什么需要经济的代码？

如果你在线购买过春运的火车票，经历过购票网站的瘫痪，你应该深有体会，网站瘫痪是一件让人多么绝望的事情。

根据有关报道，2014 年 1 月 9 日，火车票售票网站点击量高达 144 亿次，相当于每个中国人点击了 10 次，平均每秒点击了 16,000 次，峰值的点击量可能远远超出 16,000 次。这么强悍的访问量，导致了火车售票网站多次瘫痪。这是一个典型的性能错配导致的重大网络事故，处理这么大的点击量需要特殊的程序设计和架构安排。

有句俗话说：“又要马儿跑，又要马儿不吃草。”马该怎么活呀？活不了呀！要想让马跑得快，既要有好马，也要有好料。

如果可以把软件比作一匹马的话，让这匹马出生时有一个优良的基因，平日里精心地伺候，是让它跑得快的先决条件。

前一段时间，我们讨论了如何让代码“写得又快又好、读得又快又好”的话题。接下来的这段时间，我们来聊聊怎么让代码“跑得又快又好”。跑得又快又好，一般也意味着更少的运营费用。该怎么让我们写的代码有一个跑得好的基因呢？

需不需要“跑得快”的代码？

很多项目是面向订单的，代码的功能是需要优先考虑的任务。这并没有错误。如果不能兼顾性能，这个债将来还起来会很痛苦，成本很高。而且，很多情况下，它是躲不开、赖不掉的。

怎么理解代码的性能？

为了理解这个问题，我们需要对代码的性能有一个共同的认识。代码的性能并不是可以多块地进行加减乘除，而是如何管理内存、磁盘、网络、内核等计算机资源。代码的性能与编码语言关系不大，就算是 JavaScript 编写的应用程序，也可以很快，C 语言编写的程序也可能很慢。

事实上，代码的性能和算法密切相关，但是更重要的是，我们必须从架构层面来考虑性能，选择适当的技术架构和合适的算法。很多纸面上看起来优美的算法，实际上很糟糕。也有很多算法看起来不咋样，但实际上很高效。为了管理代码的性能，在一定程度上，我们需要很好地了解计算机的硬件、操作系统以及依赖库的基本运行原理和工作方式。一个好的架构师，一定会认真考虑、反复权衡性能要求。

需不需要学习性能？

一个程序员，可以从多个方面做出贡献。有人熟悉业务逻辑，有人熟悉类库接口，有人能够设计出色的用户界面。这都非常好，但是如果考察编程能力，有两件事情我们需要特别关注。

第一件事情是，我们的代码是不是正确？事实上，代码正确这个门槛特别低。如果代码出现了大范围的错误，说明编程还没有入门。

第二件事情是，我们的代码运行起来有没有效率，运营成本低不低？这也是我们判断代码是否经济的一个标准。编写经济的代码的门槛稍微高一些，它需要更多的知识和经验，但它也是能让我们脱颖而出的一个基本功。门槛越高，跨越门槛的价值就越大。我们要是一直不愿意跨越这个高门槛，面临的竞争压力就会越来越大。

这个价值到底有多大呢？就我熟悉的领域来说，如果你可以把 Java 垃圾管理器的效率提高 50%，或者把列表的查询速度提高 50%，更或者，你能用三五台服务器解决掉春运火车票售票网站崩溃的问题，那么找到一份年薪百万的工作是不难的。

当然上面的一些问题实现起来非常困难，比如提高 Java 垃圾管理器的效率。但是，需要我们解决的性能问题，很多时候，都不是技术问题，而是意识和见识的问题。成熟的解决方案就在那儿，容易理解，也容易操作。只是，我们没有想到，没有看到，也没有用到这些解决方案。我们越不重视性能，这些知识离我们就越远。

一个好的程序员，他编写的代码一定兼顾正确和效率的。事实上，只有兼顾正确和效率，编程才有挑战性，实现起来才有成就感。如果丢弃其中一个指标，那么大多数任务都是小菜一碟。

有过面试经验的小伙伴，你们有没有注意到，正确和有效地编码是面试官考察的两个重点？招聘广告可不会提到，程序员要能够编写正确的代码和有效的代码。但是一些大的企业，会考察算法，其中一条重要的评判标准就是算法够不够快。他们可能声称算法考察的是一个人的基本功，是他的聪明程度。但是如果算法设计不够快，主考官就会认为我们基本功不够扎实、不够聪明。 你看，算法快慢大多只是见识问题，但很多时候，会被迫和智商联系起来。这样做既无理，也无聊，但是我们也没有办法逃避开来，主考官可能也没有更好的办法筛选出更好的人才。

需不需要考虑代码性能？

具体到开发任务，对于软件的性能，有很多误解。这些误解，一部分来自我们每个人都难以避免的认知的局限性，一部分来自不合理的假设。

比如说，有一种常见的观点是，我们只有一万个用户，不要去操百万用户的心。这种简单粗暴的思考方式很麻烦！你要是相信这样的简单论断，肯定会懵懂得一塌糊涂。百万用户的心是什么心？你根本没有进一步思考的余地。你唯一能够理解的，大概就是性能这东西，一边儿玩去吧。

一开始，我们就希望大家能从经济的角度、从投入产出的角度、从软件的整个生命周期的角度来考虑代码。我们要尽量避免这种不分青红皂白，一刀切下去的简单方式。这种简单粗暴的方式可能会帮我们节省几秒钟的时间，我们思考的快系统喜欢这样，这是本性。但是，我们真的没必要在乎这几秒钟、几分钟，甚至是几小时，特别是在关乎软件架构和软件质量的问题上。该调用我们思考的慢系统的时候，就拿出来用一用。

我们可以问自己一些简单的问题。比如说，一万个用户会同时访问吗？如果一秒钟你需要处理一万个用户的请求，这就需要有百万用户、千万用户，甚至亿万用户的架构设计。

再比如说，会有一万个用户同时访问吗？也许系统没有一万个真实用户，但是可能会有一万个请求同时发起，这就是网络安全需要防范的网络攻击。系统保护的东西越重要，提供的服务越重要，就越要防范网络攻击。而防范网络攻击，只靠防火墙等边界防卫措施，是远远不够的，代码的质量才是网络安全防护的根本。

你看，哪怕我们没有一万个用户，我们都要操一万个用户的心；当我们操一万个用户的心的时候，我们可能还要操百万用户的心。

你有没有意识到，你操心的程度和用户量的关系不是那么紧密？你真正需要关心的，是你的代码有多重要？ 代码带来的绝对价值越大，消耗的绝对成本越高，它的性能就越重要。

当然，也不是所有的软件都值得我们去思考性能问题。有人统计过，大概 90% 以上的软件，都没有什么实际用处，也就是说，运营价值非常小。比如我们的毕业论文代码，比如入门教科书的示例代码，比如我们为公司晚会写的、用完就扔的抽奖程序。这是对的，大多数代码的性能优化是无用的，因为它们并没有多大的实际运营价值。

但是，如果我们要去开发具有商业价值的软件，就要认真思考代码的性能能够给公司带来的价值，以及需要支付的成本。

经验告诉我们，越早考虑性能问题，我们需要支付的成本就越小，带来的价值就越大。甚至是，和不考虑性能的方案相比，考虑性能的成本可能还要更小。

你可能会吃惊，难道优化代码性能是没有成本的吗？ 当然有。这个成本通常就是我们拓展视野和经验积累所需要支付的学费。这些学费，当然也变成了我们自身市场价值的一部分。

有时候，有人说：“我们只有一万个用户，不要去操百万用户的心。” 其实，潜台词是说，我们还没有技术能力去操一百万个用户的心，也没有时间或者预算去支付这样的学费。这其实对我们是有利的。一旦我们有了这样的见识和能力，我们就可以发挥市场的价值。这是一个可以赚回学费的机会，也会让我们变得越来越有价值。

什么时候开始考虑性能问题？

为了进度，很多人的选择是不考虑什么性能问题，能跑就行，先跑起来再说；先把代码摞起来，再考虑性能优化；先把业务推出去，再考虑跑得快不快的问题。可是，如果真的不考虑性能，一旦出了问题，系统崩溃，你的老板不会只骂他自己，除非他是一个优秀的领导。

硬件扩展能解决性能问题吗？

有一个想法很值得讨论。很多人认为，如果碰到性能问题，我们就增加更多的机器去解决，使用更多的内存，更多的内核，更快的 CPU。网站频繁崩溃，为什么就不能多买点机器呢？！

但遗憾的是，扩展硬件并不是总能够线性地提高系统的性能。出现性能问题，投入更多的设备，只是提高软件性能的一个特殊方法。而且，这不是一个廉价的方法。过去的经验告诉我们，提高一倍的性能，硬件投入成本高达四五倍；如果需要提高四五倍的性能，可能投入二三十倍的硬件也达不到预期的效果。硬件和性能的非线性关系，反而让代码的性能优化更有价值。

性能问题能滞后处理吗？

越来越多的团队开始使用敏捷开发模式，要求拥抱变化，快速迭代。很多人把这个作为一个借口：我们下一次迭代的时候，再讨论性能问题。他们忘了敏捷开发最重要的一个原则，就是高质量地工作。没有高质量的工作作为基础，敏捷开发模式就会越走越艰难，越走越不敏捷，越走成本越高。而性能问题，是最重要的质量指标之一。

性能问题，有很多是架构性问题。一旦架构性问题出现，往往意味着代码要推倒重来，这可不是我们可以接受的快速迭代。当然，也有很多性能问题，是技术性细节，是变化性的问题。对于这些问题，使用快速迭代就是一个经济的方式。

很多年以来，我们有一个坏的研发习惯，就是性能问题滞后处理，通过质量保证 (QA) 环节来检测性能问题，然后返回来优化性能。这是一个效率低、耗费大的流程。

当应用程序进入质量保证环节的时候，为时已晚。在前面的设计和开发阶段中，我们投入了大量时间和精力。业务也要求我们尽快把应用程序推向市场。如果等到最后一分钟，才能找到一个严重的性能问题，推迟产品的上市时间，错失市场良机，那么这个性能问题解决的成本是数量级的。没有一个企业喜欢事情需要做两遍才能做到正确的团队，所以我们需要在第一时间做到正确。

要有性能工程的思维

采用性能工程思维，才能确保快速交付应用程序，而不用担心因为性能耽误进度。性能工程思维通过流程“左移”，把性能问题从一个一次性的测试行为，变成一个贯穿软件开发周期的持续性行为；从被动地接受问题审查，变成主动地管理质量。也就是说，在软件研发的每一步，每一个参与人员，都要考虑性能问题。整个过程要有计划，有组织，能测量，可控制。

采用性能工程思维，架构师知道他们设计的架构支持哪些性能的要求；开发工程师清楚应该使用的基本技术，而不是选择性地忽略掉性能问题；项目管理人员能够在开发软件过程中跟踪性能状态；性能测试专家有时间进行负载和压力测试，而不会遇到重大意外。实现性能要求的风险在流程早期得到确认和解决，这样就能节省时间和金钱，减轻在预算范围内按时交付的压力。

现在很多公司的研发，完美地匹配了敏捷开发和性能工程这两种模式。降低研发成本的同时，也促进了员工的成长，减轻了程序员的压力。

小结

最后，我们总结一下。编写有效率的代码是我们的一项基本技能。我们千万不要忽视代码的性能要求。越早考虑性能问题，需要支付的成本就越小，带来的价值就越大，不要等到出现性能问题时，才去临时抱佛脚。另外，性能问题，大部分都是意识问题和见识问题。想得多了，见得多了，用得多了，技术就只是个选择的问题，不一定会增加我们的编码难度和成本。

接下来的这一模块，我们会聚焦在解决性能问题的一些基本思路和最佳实践上，比如架构设计问题、内存管理问题、接口设计问题和开发效率问题等等。

最后问你个问题吧，你有因为性能问题承担过巨大的压力吗？这个性能问题是怎么来的？最后怎么解决的？欢迎你在留言区分享你的想法。

一起来动手

下面的这段代码，我们前面使用了很多次，主要是为了学习编码规范。其实，它也有性能问题。这一次，我们来试着优化它的性能。

我先要说明的是，如果你之前没有接触过类似的问题，那么它是有点难度的。如果你已经接触过类似的问题，这个问题就是小菜一碟。这就是一个见了多少、经验也就有多少的问题。

欢迎你把优化的代码公布在讨论区，我们一起来看看性能优化后的代码可以是什么样的？

import java.util.HashMap;

import java.util.Map;

class Solution {

    /**

     * Given an array of integers, return indices of the two numbers

     * such that they add up to a specific target.

     */

    public int[] twoSum(int[] nums, int target) {

        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {

            int complement = target - nums[i];

            if (map.containsKey(complement)) {

                return new int[] { map.get(complement), i };

            }

            map.put(nums[i], i);

        }

        throw new IllegalArgumentException("No two sum solution");

    }

}

18丨思考框架：什么样的代码才是高效的代码？

如果让你设计一个有十亿用户使用的售票网站，你会考虑哪些问题？如果让你设计一个有一万亿用户使用的服务，你又会考虑哪些问题？不要以为有一万亿个用户的服务离我们很远，它正在快速地逼近我们。

我们前面讨论了，代码的性能是关于如何管理内存、磁盘、网络和内核等计算机资源的。该怎么衡量这些资源管理的好坏呢？这就需要一些评价指标。

这些指标不仅指导着代码的交付标准，也指导着我们编码时的技术选择。

用户的真实感受

最直接的指标就是用户的真实感受。用户的感受是我们软件开发最基本的风向标，当然也是代码性能追求的终极目标。

如果去超市买东西，我们享受的是购物的过程，讨厌结账。结账之所以令人讨厌，一小部分原因在于这时我们要付钱，更大的原因在于这个过程排队时间可能会很长。如果再算错了帐，就更让人不爽了。

用户对于软件性能的要求，和我们超市结账时的要求差不多：等待时间要短，出错的概率要小。

等待时间要短

这个概念很好理解。等待时间越短，我们越喜欢。最好是一点儿都感觉不到等待时间。使用“感觉”、“快”、“慢”这种词汇，有点主观了。有一种统计方法，被广泛地用来评价应用程序性能的满意度，它就是应用程序性能指数（Apdex）。

根据任务的响应时间，应用程序性能指数定义了三个用户满意度的区间：

满意：如果任务的响应时间小于 T，用户感觉不到明显的阻碍，就会比较满意；

容忍：如果任务的响应时间大于 T，但是小于 F，用户能感觉到性能障碍，但是能够忍受，愿意等待任务的完成；

挫败：如果任务的响应时间大于 F 或者失败，用户就不会接受这样的等待。挫败感会导致用户放弃该任务。

在互联网领域，最佳等待时间（T）和最大可容忍等待时间（F）的选择有着非常经典的经验值，那就是最佳等待时间是 2 秒以内，最大可容忍等待时间是最佳等待时间的 4 倍，也就是 8 秒以内。

有了统计数据，应用程序性能指数可以按照下属的公式计算：

Apdex = (1 × 满意样本数 ＋ 0.5 × 容忍样本数 + 0 × 挫败样本数) / 样本总数

假如有一个应用，100 个样本里，有 70 个任务的等待时间在 2 秒以内，20 个任务的等待时间大于 2 秒小于 8 秒，10 个任务的等待时间大于 8 秒。那么，这个指数的就是 80%。

Apdex = (1 × 70 ＋ 0.5 × 20 + 0 × 10) / 100
= 0.8

80 分的成绩能不能让我们满意呢？ 通常来说，80 分的成绩还算过得去，90 分以上才能算是好成绩。

需要特别注意的是，这个等待时间是用户能够感受到的一个任务执行的时间，不是我们熟悉的代码片段执行的时间。比如说，打开一个网页，可能需要打开数十个连接，下载数十个文件。对于用户而言，打开一个网页就是一个完整的、不可分割的任务。它们并不需要去理解打开网页背后的技术细节。

有了这个指数，我们就知道快是指多块，慢是指多慢；什么是满意，什么是不满意。这样我们就可以量化软件性能这个指标了，可以给软件性能测试、评级了。

体验要一致

为什么 90 分以上才算是好成绩呢？ 这就牵涉到用户体验的一致性。一致性原则是一个非常基本的产品设计原则，它同样也适用于性能的设计和体验。

一个服务，如果 10 次访问有 2 次不满意，用户就很难对这个服务有一个很高的评价。10 次访问有 2 次不满意，是不是说明用户可以给这个服务打 80 分呢？显然不是的。他们的真实感受更可能是，这个服务不及格。特别是如果有对比的话，他们甚至会觉得这样的服务真是垃圾。

如果你们了解近年来浏览器的发展历史，就会看到一个巨大的市场份额变迁。微软的 IE 浏览器在不到十年的时间内，从无可动摇的市场霸主，被谷歌的 Chrome 浏览器超越，大幅度被甩在了身后，最后被深深地踩在脚下。其中一个非常重要的因素就是，Chrome 浏览器的响应速度更快，用户体验更好。就连 Windows 的用户，都抛弃了 IE，转而使用 Chrome。不是说 IE 浏览器不好，而是相比之下，Chrome 更好。

一个服务能够提供一致的性能体验，拿到 90 分甚至 95 分以上的好成绩，其实有很多挑战。但正是这些挑战，让优秀的程序员和优秀的产品脱颖而出。

比如说，为了性能和安全，谷歌的浏览器和谷歌提供的很多服务之间，甚至抛弃了成熟通用的 TCP 协议，转向使用性能和安全性更好的 QUIC 协议。

难道财大气粗、脑力激荡的微软没有反击吗？ 反击当然有，Windows 10 启用了全新浏览器 Edge，但是没有掀起半点波澜。 2018 年 10 月，微软宣布重构 Edge 浏览器，使用谷歌的 Chrome 引擎技术。

这就是一个利用性能优势和用户体验赢得市场地位，成为后起之秀的经典案例。它告诉我们，仅仅做到好，还不能生存，要做到最好。

浏览器是客户端，服务端也需要提供一致的体验吗？

比如说，有一个服务在一年 12 个月的时间里，有 11 个月的服务都特别流畅，人人都很满意。但是有半个月，网站经常崩溃或者处于崩溃的边缘，平常需要 2 秒就搞定的服务，此时需要不停地刷屏排队，甚至 30 分钟都完成不了。但这项服务特别重要，没有可替代的，不能转身走开，只好隔几秒就刷一次屏。

手动刷屏太累呀，谁也不愿意过 5 秒点一下刷新。为了解放大家的双手、眼睛还有绝望的心，自动刷屏软件出现了，每隔几秒可以自动模拟刷屏，给大家带来了一线的生机。大家都很欢喜，纷纷安装，用过的奔走相告。久而久之使用刷屏软件的人多了，人们就更加访问不到服务了，等待时间会变得更长，于是又有更多的人使用刷屏软件，更频繁地刷屏，形成了一个恶性循环。

就这样，1 千万个人的活动，制造出了 100 亿个人的效果。我相信，只要你经历过这种让人崩溃的场景，就不会因为它有 11 个月的优良服务记录为它点赞。如果有客户评价系统的话，你大概率会给个零分，然后丢下一堆鼓励的话。如果这个服务出现了竞争者，你可能会立即走开投向新服务的怀抱。

代码的资源消耗

如何让用户对服务感到满意呢？这就需要我们通过代码管理好内存、磁盘、网络以及内核等计算机资源。

管理好计算机资源主要包括两个方面，一个方面是把有限的资源使用得更有效率，另一个方面是能够使用好更多的资源。

把资源使用得更有效率

这个概念很好理解，指的就是完成同一件事情，尽量使用最少的计算机资源，特别是使用最少的内存、最少的 CPU 以及最少的网络带宽。

愿景很美好，但是我们的确又做不到，怎么可能“又要马儿跑，又要马儿不吃草”呢？这个时候，就需要我们在这些计算机资源的使用上做出合理的选择和分配。比如通过使用更多的内存，来提高 CPU 的使用效率；或者通过使用更多的 CPU，来减少网络带宽的使用；再或者，通过使用客户端的计算能力，来减轻服务端的计算压力。

所以，有时候我们说效率的时候，其实我们说的是分配。计算机资源的使用，也是一个策略。不同的计算场景，需要匹配不同的策略。只有这样，才能最大限度地发挥计算机的整体的计算能力，甚至整个互联网的计算能力。

能够使用好更多的资源

这个概念也很好理解，就是当我们面对更多计算机资源的时候，能够用上它们、用好它们。遗憾的是，很多代码是做不到这一点的。

比如说，有一个非常成功的应用程序，受欢迎程度远远超过预期，用户量急剧攀升，系统的响应时间急剧下降，服务器面临崩溃的危险。这是值得庆贺的时刻，是不是？也是可以大胆增加投入的时机，对不对？

这时候，如果换一个 128 个内核、64TB 内存的计算机，把服务器搬到网络骨干机房，取消带宽流量限制，我们能保证这个应用程序用得上这些资源吗？能够解决眼前的危机吗？如果一台机器不够用，这个应用程序可以使用好 4 台或者 16 台计算机吗？这个，真的不一定。即便有充足的资源，应用程序的瓶颈可能也不是充沛的资源可以解决的。

不是所有的应用程序设计都能够用好更多的资源。这是我们在架构设计时，就需要认真考量的问题。

算法的复杂程度

如果给定了计算机资源，比如给定了内存，给定了 CPU，我们该怎么去衡量这些资源的使用效率？

一个最重要、最常用、最直观的指标就是算法复杂度。对于计算机运算，算法复杂度又分为时间复杂度和空间复杂度。我们可以使用两个复杂度，来衡量 CPU 和内存的使用效率。

算法复杂度的计算，我相信是大家耳熟能详的内容，我们就不在这里讨论它们的细节问题了。

小结

编写有效率的代码是我们的一项基本技能。要学会这项技能，我们就要了解该怎么去设计、分析、验证代码的效率。从小的代码层面看，我们要有意识、要能够给理解并计算算法的复杂度，来尽量提高每一段代码的效率。从大的架构层面看，我们要采用合适的技术，指导实现的代码能够把有限资源使用的更有效率，也能够在必要时使用更多的资源。从更大的产品层面看，我们一定要关切用户的使用体验和真实感受，特别是糟糕状况下的感受，及时地做出调整。

衡量代码性能的体系和指标很多，你还知道哪些方法？欢迎你分享在留言区，我们一起来学习。

一起来动手

下面的这段 Java 代码，你能够计算出它的时间复杂度和空间复杂度吗？你知道有什么工具可以分析出这段代码里，哪些地方最耗费时间吗？如果你找到了性能的瓶颈，你有优化的办法吗？

欢迎你在留言区讨论上面的问题，我们一起来看看这一小段代码，是不是可以做的更好？

import java.util.HashMap;

import java.util.Map;

class Solution {

    /**

     * Given an array of integers, return indices of the two numbers

     * such that they add up to a specific target.

     */

    public int[] twoSum(int[] nums, int target) {

        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {

            int complement = target - nums[i];

            if (map.containsKey(complement)) {

                return new int[] { map.get(complement), i };

            }

            map.put(nums[i], i);

        }

        throw new IllegalArgumentException("No two sum solution");

    }

}

19 | 怎么避免过度设计？

俗话说，“过犹不及”。“过度”这个词仿佛会给我们一些不好的暗示。不要紧张，我们先聊一个轻松的话题。

假设有一个小地方，要建一个火车站。这个地方有数十万人口，每列火车预计上下乘客数十人，高峰时段大概近百人。你会怎么设计这个火车站？

这个火车站可能是个富丽堂皇的建筑，有宽敞的售票厅和候车室。这种设计到处可见，你可以想一想你熟悉的火车站， 也可以观察一下旅途中的火车站。

也有些火车站可能只是一个一百平米左右的小房子，只有简单的售票窗口、进站口和出站口。 比如说北京的清华园火车站，就是这样的。

也有的火车站只有标牌、售票机和遮阳棚的一小块地方，告诉人们火车在这儿停靠，就像我们常见的公交车站。

这三种火车站，都能实现旅客购票、候车、上车和下车的核心需求，帮助他们实现乘车旅行的目的。

既然乘坐火车的核心需求基本是一样的，为什么车站的差别这么大呢？

乘车旅行这个需求，衍生出了购票、候车、上车和下车的需求。

购票的需求衍生出了售票、购票、验票、检票以及各个环节排队的需求。

售票的需求衍生出了要有售票办公室和售票大厅、管理售票人员、购票人员和票贩子的需求。

售票办公室衍生出了科长办公室、科员办公室、会议室、售票窗口。售票窗口的需求也可以接着衍生出更多的需求。这个列表我们可以列很长很长，最后的结果就是火车站的建设耗资大，建设周期长，运营成本高。

哪一种火车站对旅客更方便呢？如果在一个小地方，那么第三种火车站旅客上车的环节最少，是最方便的。而且投资小，建设周期短，运营成本低。

软件开发和建火车站一样，都有设计、建设、运营和维护的环节。该怎么管理好需求和设计，是工程设计者需要重点考虑的问题。

避免需求膨胀

软件开发过程中，最让人痛苦的是什么？如果有这么一个调查的话，“频繁的需求变更”应该是一个高票选项。

频繁的需求变更确实让人抓狂。它变更的可不仅仅只是需求，还有不断重构的代码，不断延长的工期，不断增长的投入，以及越来越多的加班。

在一个五彩缤纷的世界里，拥有多种多样的观点，坚持不懈地改进，是一件好事情。但是，“多姿多彩”对于计算机程序而言，就是个巨大的挑战。现实世界需要丰富，而抽象程序则需要简化。这对不可避免的矛盾，就成了所有程序员的心头刺。

软件是为现实服务的，而现实总是变化的。作为程序员，我们是没有办法抵制住所有的需求变更的。为了限制无节制的需求变更，适应合理的需求进化，我们要使用两个工具，一个工具是识别最核心需求，另一个工具是迭代演进。

识别最核心需求

一个经济的系统，需要从小做起，而不是一上来就胡子眉毛一把抓，什么都要做。什么都要做的结果是什么都做不好。

要从小做起，最重要的就是选择。什么是必须做的？什么是现在就要做的？这是我们做选择时，要时刻准备提出和回答的两个问题。

回答这两个问题，有时候并不容易。我们知道的越多，见识越广，这两个问题越难回答。比如说开头中提到的火车站的建设。既然建造公交车站一样的火车站又方便、又省钱，为什么还要建造富丽堂皇的火车站呢？岂不是又费事又费钱？

但是，专家有他们的考量。逃票问题、安全问题、舒适问题、管理问题、就业问题等，都在他们的考虑范围内。

作为程序员，或者项目经理，我们懂得一大把的原理，学了一大把的技术，手里有一大把工具。这些技术运用起来，就是一个丰富的大世界。我们的很多需求，来源于心里的推断，而不是眼前的事实。推断产生需求，催生的系统就会形成新的事实，强化推断的演进。为了解决了不存在的问题，我们制造出真实存在的问题。

我第一次见到像公交车站一样的火车站时，心里想，这也算火车站吗？多多少少有点震惊。我真的没有见过这么简单的火车站。有一段时间，我每天都要经过这个车站，也没发现什么不妥的地方。只要提前 30 秒到达火车站，就能赶上准时出发的火车，像坐公交车一样很方便。我之所以觉得它方便，因为我是乘客。

如果从最终用户的眼里看软件，类似于从乘客的眼里看火车站。很多软件，承载了太多中间客户的期望和推断，最终用户的真实需求和关键需求反而被膨胀的无效需求弱化了。

所以，我们要回归到最终用户。只有从最终用户的眼里看需求，才能够识别什么是最核心的需求，什么是衍生的需求，什么是无效的需求。这样，我们才能找到一个最小的子集，那就是现在就必须满足的需求。

首先就必须满足的需求，是优先级最高的、最重要的事情，这些事情要小而精致，是我们的时间、金钱、智力投入效率最高的地方，也是回报最丰厚的地方。我们要把这些事情做到让竞争对手望尘莫及的地步。

不要一步到位

有一些需求很重要，但不是现在就必须做的。这就需要另外一个方法——迭代演进。第一次我们没有办法完成的事情，就放在第二次考虑。

迭代演进不仅仅需要考虑上一次没有完成的事情，还要考虑变化促生的新需求。所以，在这一步，还要像第一次一样，先找到最小的子集，也就是现在就必须满足的需求。然后，全力以赴地做好它。

这样迭代了几轮之后，一定有一些第一次看起来很重要的需求，再看反而不重要了，根本就不需要解决。

在 OpenJDK 社区中，每年都会关闭一些有些年头的需求请求。这些需求，要么没有真实用户，要么已经有了替代的解决方案，要么就是已经被抛弃的技术。所以一些曾经看起来值得考虑的需求，时间为我们过滤掉了它们。

是不是迭代的时候，就可以考虑一些不重要的需求了呢？不，永远不要考虑不重要的需求。有时候，遏制住添加新功能、新接口的渴望，是一个困难的事情。我们需要学会放手，学会休假，以及拥有空闲时间。

管理好需求，是提高我们的工作效率以及软件效率最有效路径。但遗憾的是，我们不是总有机会决定软件需求的范围，以及优先顺序。

幸运的是，我们是产品的设计者和生产者，代码该怎么写，我们有很多话语权。

避免过度设计

其实和需求一样，设计也是一个容易膨胀的环节。 看看下面的漫画，是不是有些好笑又熟悉？我们只是需要一点盐，设计师会设计一个能给我们任何调味品的接口。设计接口系统会耗费很多时间，但设计师认为这会节省我们未来的时间。

遗憾的是，对软件来说，过度设计的接口意味着更多的代码、更多的维护、更多的修修补补，未来也不会节省我们的时间。

费迪南德·保时捷曾经说过：“一辆完美的跑车，应该首先越过终点，然后立即陷入困境。”这多少有点苛刻，但这就是“少就是多”的极简主义追求。

过度设计导致过度复杂，过度复杂导致效率降低、危险加剧、性能降低。如果保持简单而不是复杂化，大多数系统都能发挥最佳作用。这就是“少就是多”的魅力。

避免过度设计，和避免需求膨胀一样，我们要时刻准备提问和回答的两个问题：什么是必须做的？什么是现在就必须做的？

这两个问题时常提问、经常回答，有助于我们始终在用户的需求范围内思考设计，有助于我们始终关注核心问题，并且保持设计方案的简介、优雅。

小结

影响代码效率的最重要的两件事情，就是需求的膨胀和过度的设计。为了这两个问题，我们需要回答两个问题：

什么是必须做的？

什么是现在就必须做的？

弄清楚这两个问题后，我们需要做的，就是做好现在就必须做的事情。

一起来动手

克制住过度设计的倾向，这需要非凡的自律和自信。有时候我就想，微信的团队到底是怎么克制住自己，让微信简洁的页面保持了这么多年。那么多的诱惑，那么多流量变现的办法，都能抵制住，得要有多强大的内心和清醒的认识！

微信的聊天页面是我们最关心的信息：谁发送了信息。一对一的聊天界面，永远只使用窄窄的一行，来完成丰富的功能，红包、语音、表情包、贴图，都可以在这一行完成。所有的其他功能，比如小程序，朋友圈、合作商家，都不能干扰核心功能的呈现。现在我们看着可能觉得很简单，其实这样的设计真的很难，真的很了不起。如果不相信的话，我们来做一做练手题。

这一次的练手题，我想请你思考一个银行账户管理 App，有哪些必须要做的事情。作为一个用户，你最关心的账户管理内容是什么？然后，你看下常用银行的 App，看一看你最关心的内容，需要多少步操作才可以获得，也想一想哪一些内容你会毫不犹豫地删掉。

欢迎你在留言区留言，分享你的看法。也欢迎点击“请朋友读”，把这篇文章分享给你的朋友或者同事，一起交流一下。

20 | 简单和直观，是永恒的解决方案

上一次，我们聊了影响代码效率的两个最重要的因素，需求膨胀和过度设计。简单地说，就是找到要做的事情，做的事情要少。接下来，我们来聊聊怎么做这些事情。其中，我认为最重要的原则就是选择最简单、最直观的做法。反过来说，就是不要把事情做复杂了。

要想简单直观，我们要解决两个问题。 第一个问题是，为什么要简单直观？第二个问题是，该怎么做到简单直观？

为什么需要简单直观？

简单直观，看似是一条每个人都能清楚明白的原则。事实上，这是一个非常容易被忽略的原则。如果我们没有对简单直观这个原则有一个基本的认识，就不太可能遵循这样的原则。

我们都喜欢原创和挑战，来展示我们的聪明才智。而简单直观的事情，显而易见的解决办法，似乎不足以展示我们的智慧和解决问题的能力。

遗憾的是，在软件世界里，一旦我们脱离了简单直接的原则，就会陷入行动迟缓、问题倍出的艰难境地。简洁是智慧的灵魂，我们要充分理解这一点。

简单直观是快速行动的唯一办法

我们真正要的不是简单直观的代码，而是轻松快速的行动。编写简单直观的代码只是我们为了快速行动而不得不采取的手段。有一个说法，如果面条代码能够让我们行动更快，我们就会写出面条代码，不管是刀削面还是担担面。

我见过的优秀的程序员，无一例外，都对简洁代码有着偏执般的执着。甚至小到缩进空格该使用几个空格这样细枝末节的问题，都会严格地遵守编码的规范。乍一看，纠缠于缩进空格不是浪费时间吗？可是真相是，把小问题解决好，事实上节省了大量的时间。

这些对代码整洁充满热情的工程师，会对整个团队产生积极的、至关重要的影响。这种影响，不仅仅关乎到工程进展的速度，还关系到工程的质量。真正能够使得产品获得成功，甚至扭转科技公司命运的，不是关键时刻能够救火的队员，而是从一开始就消除了火灾隐患的队员。

简单直观减轻沟通成本

简单直观的解决方案，有一个很大的优点，就是容易理解，易于传达。事情越简单，理解的门槛越低，理解的人越多，传达越准确。一个需要多人参与的事情，如果大家都能够清晰地理解这件事情，这就成功了一半。

我们不要忘了，客户也是一个参与者。简单直观的解决方案，降低了用户的参与门槛，减轻了学习压力，能够清晰地传递产品的核心价值，最有可能吸引广泛的用户。

简单直观降低软件风险

软件最大的风险，来源于软件的复杂性。软件的可用性，可靠性，甚至软件的性能，归根到底，都是软件的复杂性带来的副产品。越复杂的软件，我们越难以理解，越难以实现，越难以测量，越难以实施，越难以维护，越难以推广。如果我们能够使用简单直接的解决方案，很多棘手的软件问题都会大幅地得到缓解。

如果代码风格混乱，逻辑模糊，难以理解，我们很难想象，这样的代码会运行可靠。

该怎么做到简单直观？

如果我们达成了共识，要保持软件的简单直观，那么，我们该怎么做到这一点呢？最重要的就是做小事，做简单的事情。

使用小的代码块

做小事的一个最直观的体现，就是代码的块要小，每个代码块都要简单直接、逻辑清晰。整洁的代码读起来像好散文，赏心悦目，不费力气。

如果你玩过乐高积木，或者组装过宜家的家具，可能对“小部件组成大家具”的道理会深有体会。代码也是这样，一小块一小块的代码，组合起来，可以成就大目标。作为软件的设计师，我们要做的事情，就是识别、设计出这些小块。如果有现成的小块代码可以复用，我们就拿来用。如果没有现成的，我们就自己来实现这些代码块。

为了保持代码块的简单，给代码分块的一个重要原则就是，一个代码块只做一件事情。前面，我们曾经使用过下面的例子。这个例子中，检查用户名是否符合用户名命名的规范，以及检查用户名是否是注册用户，放在了一个方法里。

/**

 * Check if the {@code userName} is a registered name.

 *

 * @return true if the {@code userName}is a registered name.

 * @throws IllegalArgumentException if the {@code userName} is invalid

 */

boolean isRegisteredUser(String userName) {

    // snipped

}

如果单纯地从代码分块来看，还有优化的空间。我们可以把上述的两件事情，分别放到一个方法里。这样，我们就有了两个可以独立使用的小部件。每个小部件都目标更清晰，逻辑更直接，实现更简单。

/**

 * Check if the {@code userName} is a valid user name.

 *

 * @return true if the {@code userName} is a valid user name.

 */

boolean isValidUserName(String userName) {

    // snipped

}

/**

 * Check if the {@code userName} is a registered name.

 *

 * @return true if the {@code userName} is a registered name.

 */

boolean isRegisteredUser(String userName) {

    // snipped

}

遵守约定的惯例

把代码块做小，背后隐含一个重要的假设：这些小代码块要容易组装。不能进一步组装的代码，如同废柴，没有一点儿价值。

而能够组装的代码，接口规范一定要清晰。越简单、越规范的代码块，越容易复用。这就是我们前面反复强调的编码规范。

花时间做设计

对乐高或者宜家来说，我们只是顾客，他们已经有现成的小部件供我们组合。对于软件工程师而言，我们是软件的设计者，是需要找出识别、设计和实现这些小部件的人。

识别出这些小部件，是一个很花时间的事情。

有的程序员，喜欢拿到一个问题，就开始写代码，通过代码的不断迭代、不断修复来整理思路，完成设计和实现。这种方法的问题是，他们通常非常珍惜自己的劳动成果，一旦有了成型的代码，就会像爱护孩子一般爱护它，不太愿意接受新的建议，更不愿意接受大幅度的修改。结果往往是，补丁摞补丁，代码难看又难懂。

有的程序员，喜欢花时间拆解问题，只有问题拆解清楚了，才开始写代码。这种方法的问题是，没有代码的帮助，我们很难把问题真正地拆解清楚。这样的方法，有时候会导致预料之外的、严重的架构缺陷。

大部分的优秀的程序员，是这两个风格某种程度的折中，早拆解、早验证，边拆解、边验证，就像剥洋葱一样。

拆解和验证，看起来很花时间。是的，这两件事情的确很耗费时间。但是，如果我们从整个软件的开发时间来看，这种方式也是最节省时间的。如果拆解和验证做得好，代码的逻辑就会很清晰，层次会很清楚，缺陷也少。

一个优秀的程序员，可能 80% 的时间是在设计、拆解和验证，只有 20% 的时间是在写代码。但是，拿出 20% 的时间写的代码，可能要比拿出 150% 时间写的代码，还要多，还要好。这个世界真的不是线性的。

有一句流传的话，说的是“跑得慢，到得早”。这句话不仅适用于健身，还适用于写程序。

借助有效的工具

我自己最常使用的工具，就是圆珠笔和空白纸。大部分问题，一页纸以内，都可以解决掉。当然，这中间的过程，可能需要一打甚至一包纸。

一旦问题有点大，圆珠笔和空白纸就不够用了。这时候，我们需要称手的工具，帮助我们记忆和思考。

现在我最喜欢的工具有思维导图、时序图和问题清单。在拆解问题时，思维导图可以帮助我厘清思路，防止遗漏。时序图可以帮助我理解关键的用例，勾画清楚各个部件之间的联系。而问题清单，可以记录下要解决和已经解决的问题，帮助我记录状态、追踪进度。

你最顺手的工具是什么？欢迎你分享在留言区，我们一起来学习。

小结

今天，我们主要聊的话题，就是做小事。我们工作生活中，一旦出现两种以上的竞争策略，要记住这个经过实践检验的理念：选择最简单，最直观的解决方案。

当然，我们遇到的不会总是简单的问题。 如果把复杂的问题、大的问题，拆解成简单的问题、小的问题，我们就能够化繁为简，保持代码的整洁和思路的清晰。

一起来动手

通常一个用户登录的设计，需要输入用户名和密码。用户名和密码一起传输到服务器进行校验，授权用户登录。但现在有了更先进的设计。用户先输入用户名，用户名通过服务器检验，才能进一步输入密码，然后授权用户登录。

你愿不愿意分析一下，这种简单的流程变化，带来的收益？ 客户端和服务器端的接口代码，大致应该是什么样子的？你使用了什么工具来分析这些问题？

欢迎你在留言区讨论上面的问题，我们一起来看看这种简单的变化可以带来什么样的好处。

21 | 怎么设计一个简单又直观的接口？

我们前面聊过接口规范，开放的接口规范是使用者和实现者之间的合约。既然是合约，就要成文、清楚、稳定。合约是好东西，它可以让代码之间的组合有规可依。但同时它也是坏东西，让接口的变更变得困难重重。

接口设计的困境，大多数来自于接口的稳定性要求。摆脱困境的有效办法不是太多，其中最有效的一个方法就是要保持接口的简单直观。那么该怎么设计一个简单直观的接口呢？

从问题开始

软件接口的设计，要从真实的问题开始。

一个解决方案，是从需要解决的现实问题开始的。要解决的问题，可以是用户需求，也可以是现实用例。面对要解决的问题，我们要把大问题分解成小问题，把小问题分解成更小的问题，直到呈现在我们眼前的是公认的事实或者是可以轻易验证的问题。

比如说，是否可以授权一个用户使用某一个在线服务呢？这个问题就可以分解为两个小问题：

该用户是否为已注册的用户？

该用户是否持有正确的密码？

我们可以使用思维导图来描述这个分解。

分解问题时，我们要注意分解的问题一定要“相互独立，完全穷尽”（Mutually Exclusive and Collectively Exhaustive）。这就是 MECE 原则。使用 MECE 原则，可以帮助我们用最高的条理化和最大的完善度理清思路。

如何理解这个原则呢？

先来说一下“相互独立”这个要求。问题分解后，我们要仔细琢磨，是不是每一个小问题都是独立的，都是可以区分的事情。

我们以上面的分解为例子，仔细看会发现这种划分是有问题的。因为只有已经注册的用户，才会持有正确的密码。而且，只有持有正确密码的用户，才能够被看作是注册用户。这两个小问题之间，存在着依赖关系，就不能算是“相互独立”。

我们要消除掉这种依赖关系。

变更后，就需要两个层次的表达。第一个层次问题是，该用户是否为已注册的用户？这个问题，可以进一步分解为两个更小的问题：用户持有的用户名是否已注册？ 用户持有的密码是否匹配？

该用户是否是已注册的用户？

a. 用户名是否已注册？

b. 用户密码是否正确？

这种描述的思维导图，和上面的相比，已经有了很大的差别。

除了每一项都要独立之外，我们还要琢磨，是不是把所有能够找到的因素，都找到了？也就是说，我们是否穷尽了所有的内容，做到了“完全穷尽”？

你可能早已经注意到了上述问题分解的缺陷。如果一个服务，对所有的注册用户开放，上面的分解就是完备的。否则，我们就漏掉了一个重要的内容，不同的注册用户，可以访问的服务可能是不同的。也就是说如果没有访问的权限，那么即使用户名和密码正确也无法访问相关的服务。

如果我们把漏掉的加上，这个问题的分解可以进一步表示为：

该用户是否是已注册的用户？

a. 用户名是否已注册？

b. 用户密码是否正确？

2. 该用户是否有访问的权限？

完成上述的分解后，对于是否授权用户访问一个服务这个问题，我们就会有一个清晰的思路了。

为什么从问题开始？

为什么我们要遵循“相互独立，完全穷尽”的原则呢？

只有完全穷尽，才能把问题解决掉。否则，这个解决方案就是有漏洞的，甚至是无效的。

只有相互独立，才能让解决方案简单。否则，不同的因素纠缠在一起，既容易导致思维混乱，也容易导致不必要的复杂。

还有一个问题，我们也要清楚地理解。那就是，为什么要从问题开始呢？

从问题开始，是为了让我们能够找到一条主线。然后，围绕这条主线，去寻找解决问题的办法，而不是没有目标地让思维发散。这样，也可以避免需求膨胀和过度设计。

比如说，如果没有一条主线牵制着，按照面向对象编程的思路，我们看到“用户”两个字，马上就会有无限的联想。是男的还是女的呀？姓啥名谁呀？多大岁数了？家住哪儿啊？一系列问题都会冒出来，然后演化成一个庞大的对象。但事实上，对于上面的授权访问问题，我们根本不需要知道这些。

自然而来的接口

把大问题分解成小问题，再把小问题分解成更小的问题。在这个问题逐层分解的过程中，软件的接口以及接口之间的联系，也就自然而然地产生了。这样出来的接口，逻辑直观，职责清晰。对应的，接口的规范也更容易做到简单、稳定。

还记得我们前面说过的 Java 的命名规范吗？Java 类的标识符使用名词或者名词短语，接口的标识符使用名词、名词短语或者形容词，方法的标识符使用动词或者动词短语。这背后的逻辑是，Java 类和接口，通常代表的是一个对象；而 Java 的方法，通常代表的是一个动作。

我们在分解问题的过程中，涉及到的关键的动词和动词短语、名词和名词短语或者形容词，就是代码中类和方法的现实来源。比如，从上面的问题分解中，我们很容易找到一个基础的小问题：用户名是否已注册。这个小问题，就可以转换成一个方法接口。

我们前面讨论过这个接口。下面，我们再来看看这段使用过的代码，你有没有发现什么不妥的地方？

/**

 * Check if the {@code userName} is a registered name.

 *

 * @return true if the {@code userName} is a registered name.

 */

boolean isRegisteredUser(String userName) {

    // snipped

}

不知道你看到没有，这个方法的命名是不妥当的。

根据前面的问题分解，我们知道，判断一个用户是不是注册用户，需要两个条件：用户名是否注册？密码是否正确？

上面例子中，这个方法的参数，只有一个用户名。这样的话，只能判断用户名是不是已经被注册，还判断不了使用这个用户名的用户是不是真正的注册用户。

如果我们把方法的名字改一下，就会更符合这个方法的职能。

/**

 * Check if the {@code userName} is a registered name.

 *

 * @return true if the {@code userName} is a registered name.

 */

boolean isRegisteredUserName(String userName) {

    // snipped

}

如果你已经理解了我们前面的问题分解，你就会觉得原来的名字有点儿刺眼或者混乱。这就是问题分解带给我们的好处。问题的层层简化，会让接口的逻辑更直观，职责更清晰。这种好处，也会传承给后续的接口设计。

一个接口一件事情

前面，我们提到过一行代码只做一件事情，一块代码只做一件事情。一个接口也应该只做一件事情。

如果一行代码一件事，那么一块代码有七八行，不是也应该做七八件事情吗？怎么能说是一件事情呢？这里我们说的“事情”，其实是在某一个层级上的一个职责。授权用户访问是一件完整、独立的事情；判断一个用户是否已注册也是一件完整、独立的事情。只是这两件事情处于不同的逻辑级别。也就是说，一件事情，也可以分几步完成，每一步也可以是更小的事情。有了逻辑级别，我们才能分解问题，接口之间才能建立联系。

对于一件事的划分，我们要注意三点。

一件事就是一件事，不是两件事，也不是三件事。

这件事是独立的。

这件事是完整的。

如果做不到这三点，接口的使用就会有麻烦。

比如下面的这段代码，用于表示在不同的语言环境下，该怎么打招呼。在汉语环境下，我们说“你好”，在英语环境下，我们说“Hello”。

/**

 * A {@code HelloWords} object is responsible for determining how to say

 * "Hello" in different language.

 */

class HelloWords {

    private String language = "English";

    private String greeting = "Hello";

    // snipped

    /**

     * Set the language of the greeting.

     *

     * @param language the language of the greeting.

     */

    void setLanguage(String language) {

        // snipped

    }

    /**

     * Set the greetings of the greeting.

     *

     * @param language the greetings of the greeting.

     */

    void setGreeting(String greeting) {

        // snipped

    }

    // snipped

}

这里涉及两个要素，一个是语言（英语、汉语等），一个是问候语（Hello、你好等）。上面的这段代码，抽象出了这两个要素。这是好的方面。

看起来，有两个独立的要素，就可以有两个独立的方法来设置这两个要素。使用 setLanguage() 设置问候的语言，使用 setGreeting() 设置问候的问候语。看起来没什么毛病。

但这样的设计对用户是不友好的。因为 setLanguage() 和 setGreeting() 这两个方法，都不能表达一个完整的事情。只有两个方法合起来，才能表达一件完整的事情。

这种互相依赖的关系，会导致很多问题。 比如说：

使用时，应该先调用哪一个方法？

如果语言和问候语不匹配，会出现什么情况？

实现时，需不需要匹配语言和问候语？

实现时，该怎么匹配语言和问候语？

这些问题，使用上面示例中的接口设计，都不好解决。 一旦接口公开，软件发布，就更难解决掉了。

减少依赖关系

有时候，“一个接口一件事情”的要求有点理想化。如果我们的设计不能做到这一点，一定要减少依赖关系，并且声明依赖关系。

一般来说一个对象，总是先要实例化，然后才能调用它的实例方法。构造方法和实例方法之间，就有依赖关系。这种依赖关系，是规范化的依赖关系，有严格的调用顺序限制。编译器可以帮我们检查这种调用顺序。

但是，我们自己设计的实例方法之间的依赖关系，就没有这么幸运了。这就要求我们弄清楚依赖关系，标明清楚依赖关系、调用顺序，以及异常行为。

下面的这段代码，摘录自 OpenJDK。这是一个有着二十多年历史的，被广泛使用的 Java 核心类。这段代码里的三个方法，有严格的调用顺序要求。要先使用 initSign() 方法，再使用 update() 方法，最后使用 sign() 方法。这些要求，是通过声明的规范，包括抛出异常的描述，交代清楚的。

/*

 * Copyright (c) 1996, 2018, Oracle and/or its affiliates. All rights reserved.

 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.

 *

 * <snipped>

 */

package java.security;

import java.security.InvalidKeyException;

import java.security.PrivateKey;

import java.security.SignatureException;

import java.security.SignatureSpi;

/**

 * The Signature class is used to provide applications the functionality

 * of a digital signature algorithm. Digital signatures are used for

 * authentication and integrity assurance of digital data.

 *

 * <snipped>

 *

 * @since 1.1

 */

public abstract class Signature extends SignatureSpi {

    // snipped

    /**

     * Initialize this object for signing. If this method is called

     * again with a different argument, it negates the effect

     * of this call.

     *

     * @param privateKey the private key of the identity whose signature

     * is going to be generated.

     *

     * @exception InvalidKeyException if the key is invalid.

     */

    public final void initSign(PrivateKey privateKey)

            throws InvalidKeyException {

        // snipped

    }

    /**

     * Updates the data to be signed or verified, using the specified

     * array of bytes.

     *

     * @param data the byte array to use for the update.

     *

     * @exception SignatureException if this signature object is not

     * initialized properly.

     */

    public final void update(byte[] data) throws SignatureException {

        // snipped

    }

    /**

     * Returns the signature bytes of all the data updated.

     * The format of the signature depends on the underlying

     * signature scheme.

     *

     * <p>A call to this method resets this signature object to the state

     * it was in when previously initialized for signing via a

     * call to {@code initSign(PrivateKey)}. That is, the object is

     * reset and available to generate another signature from the same

     * signer, if desired, via new calls to {@code update} and

     * {@code sign}.

     *

     * @return the signature bytes of the signing operation's result.

     *

     * @exception SignatureException if this signature object is not

     * initialized properly or if this signature algorithm is unable to

     * process the input data provided.

     */

    public final byte[] sign() throws SignatureException {

        // snipped

    }

    // snipped

}

然而，即使接口规范里交待清楚了严格的调用顺序要求，这种设计也很难说是一个优秀的设计。用户如果不仔细阅读规范，或者是这方面的专家，很难第一眼就对调用顺序有一个直观、准确的认识。

这就引出了另一个要求，接口一定要“皮实”。

使用方式要“傻”

所有接口的设计，都是为了最终的使用。方便、皮实的接口，才是好用的接口。接口要很容易理解，能轻易上手，这就是方便。此外还要限制少，怎么用都不容易出错，这就是皮实。

上面的 OpenJDK 例子中，如果三个方法的调用顺序除了差错，接口就不能正常地使用，程序就不能正常地运转。既不方便，也不皮实。

小结

今天，我们主要讨论了该怎么设计简单直观的接口这个话题。这是一个很大的话题。我们只讨论了最基本的原则，那就是：

从真实问题开始，把大问题逐层分解为“相互独立，完全穷尽”的小问题；

问题的分解过程，对应的就是软件的接口以及接口之间的联系；

一个接口，应该只做一件事情。如果做不到，接口间的依赖关系要描述清楚。

另外，关于面向对象设计，有一个简称为 SOLID 的面向对象设计五原则。如果你没有了解过这些原则，我也建议你找来看看。也欢迎你在留言区分享你对这些原则的理解和看法。

一起来动手

下面的这段代码，摘录自 OpenJDK，是上面那个例子的扩充版。如果从面向对象的角度来看，这样的设计也许是无可厚非的。但是这种设计存在着很多的缺陷，也带来了越来越多的麻烦。这是一个现实存在的问题，直到 OpenJDK 12，这些缺陷还没有改进。

你试着找一找，看看能发现哪些缺陷，有没有改进的办法。欢迎你把发现的缺陷，以及优化的接口公布在讨论区，也可以写一下你的优化思路。说不定，你可以为 OpenJDK 社区，提供一个有价值的参考意见或者改进方案。

也欢迎点击“请朋友读”，和你的朋友一起交流一下这段代码。

/*

 * Copyright (c) 1996, 2018, Oracle and/or its affiliates. All rights reserved.

 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.

 *

 * <snipped>

 */

package java.security;

import java.security.InvalidAlgorithmParameterException;

import java.security.InvalidKeyException;

import java.security.PrivateKey;

import java.security.PublicKey;

import java.security.SignatureException;

import java.security.SignatureSpi;

import java.security.spec.AlgorithmParameterSpec;

/**

 * The Signature class is used to provide applications the functionality

 * of a digital signature algorithm. Digital signatures are used for

 * authentication and integrity assurance of digital data.

 *

 * <snipped>

 *

 * @since 1.1

 */

public abstract class Signature extends SignatureSpi {

    // snipped

    /**

     * Initializes this signature engine with the specified parameter set.

     *

     * @param params the parameters

     *

     * @exception InvalidAlgorithmParameterException if the given parameters

     * are inappropriate for this signature engine

     *

     * @see #getParameters

     */

    public final void setParameter(AlgorithmParameterSpec params)

            throws InvalidAlgorithmParameterException {

        // snipped

    }

    /**

     * Initializes this object for verification. If this method is called

     * again with a different argument, it negates the effect

     * of this call.

     *

     * @param publicKey the public key of the identity whose signature is

     * going to be verified.

     *

     * @exception InvalidKeyException if the key is invalid.

     */

    public final void initVerify(PublicKey publicKey)

            throws InvalidKeyException {

        // snipped

    }

    /**

     * Initialize this object for signing. If this method is called

     * again with a different argument, it negates the effect

     * of this call.

     *

     * @param privateKey the private key of the identity whose signature

     * is going to be generated.

     *

     * @exception InvalidKeyException if the key is invalid.

     */

    public final void initSign(PrivateKey privateKey)

            throws InvalidKeyException {

        // snipped

    }

    /**

     * Updates the data to be signed or verified, using the specified

     * array of bytes.

     *

     * @param data the byte array to use for the update.

     *

     * @exception SignatureException if this signature object is not

     * initialized properly.

     */

    public final void update(byte[] data) throws SignatureException {

        // snipped

    }

    /**

     * Returns the signature bytes of all the data updated.

     * The format of the signature depends on the underlying

     * signature scheme.

     *

     * <p>A call to this method resets this signature object to the state

     * it was in when previously initialized for signing via a

     * call to {@code initSign(PrivateKey)}. That is, the object is

     * reset and available to generate another signature from the same

     * signer, if desired, via new calls to {@code update} and

     * {@code sign}.

     *

     * @return the signature bytes of the signing operation's result.

     *

     * @exception SignatureException if this signature object is not

     * initialized properly or if this signature algorithm is unable to

     * process the input data provided.

     */

    public final byte[] sign() throws SignatureException {

        // snipped

    }

    /**

     * Verifies the passed-in signature.

     *

     * <p>A call to this method resets this signature object to the state

     * it was in when previously initialized for verification via a

     * call to {@code initVerify(PublicKey)}. That is, the object is

     * reset and available to verify another signature from the identity

     * whose public key was specified in the call to {@code initVerify}.

     *

     * @param signature the signature bytes to be verified.

     *

     * @return true if the signature was verified, false if not.

     *

     * @exception SignatureException if this signature object is not

     * initialized properly, the passed-in signature is improperly

     * encoded or of the wrong type, if this signature algorithm is unable to

     * process the input data provided, etc.

     */

    public final boolean verify(byte[] signature) throws SignatureException {

        // snipped

    }

}

22丨高效率，从超越线程同步开始！

线程的同步是学习一门编程语言的难点。刚开始线程同步的困难，主要在于了解技术；跨过了基本技术的门槛后，更难的是掌握最基本的概念。

学习技术时，我们对基本概念熟视无睹，只想将宝剑尽快握在手，哪管宝剑何时该挥出的教导。学会技术后，基本概念就会回来找我们算旧账，出错一次剑，就记一笔账。账本慢慢变厚的过程，也是我们向基本概念靠拢的过程。当我们掌握了最基本的概念后，开始慢慢还账，账本再越变越薄。

不单单是线程和同步，掌握好基本概念，几乎是我们学习所有技术背后的困境。这怨不得我们自己，我们认识一件事情的过程，大抵就是这样。

如果有人很早地就敲着桌子，不厌其烦地重复着基本的概念，事情会不会容易一些？这一次，我们聊聊线程同步的基本概念，以及如何超越线程同步。

什么时候需要同步？

线程有两个重要的特征，就是并发执行和共享进程资源。

你可以把进程想象成一个鱼缸。鱼缸里的金鱼可以看作线程。鱼缸里的碎石、水草、鱼食等可以看作共享的资源。每一条鱼都独立行动，随时可以吐个气泡，吃点鱼食，耍弄下水草。

鱼缸里的碎石、水草，小鱼儿搬不走、吃不掉，是一个不变的量。鱼食和气泡就不一样了，每一条小鱼儿随时都会吐泡泡、吃鱼食，改变气泡和鱼食的数量。鱼食和气泡，是鱼缸里的可变量。

如果有一条小鱼儿，想要数数有多少气泡，麻烦就来了，小鱼儿要吐出新泡泡，水面的旧泡泡要破掉，怎么数都跟不上变化的节奏。怎么办呢？要让变化停止，数清楚之前，其他的小鱼儿不能吐新泡泡，水面的泡泡也不能破掉。数清楚后，再恢复行动。这就像是线程的同步。

线程的并发执行和共享进程资源，是为了提高效率。可是线程间如何管理共享资源的变化，却是一个棘手的问题，甚至是一个损害效率的问题。如果有两个以上的线程，关心共享资源的变化，一旦共享资源发生变化，就需要同步。线程同步的意思，就是一个接一个来，上一个线程没有完成一项任务之前，下一个线程不能开始相关的行动。简单地说，就是排队。

什么时候需要同步呢？需要同步的场景，要同时满足三个条件：

使用两个以上的线程；

关心共享资源的变化；

改变共享资源的行为。

同步是损害效率的

假设一条小鱼吐一个泡泡 1 秒钟，如果没什么限制，10 条小鱼 1 秒钟就可以吐 10 个泡泡。可是，如果要小鱼排队吐泡泡，10 条小鱼 1 秒钟最多只能吐 1 个泡泡，这还没算上小鱼儿交接的时间。实际上，10 条排队的小鱼 1 秒钟可能只能吐 0.9 个泡泡，因为交接也是要费时间的。

线程同步也是这样的，同步需要排队，同步的管理需要时间。所以，实践中，我们要想尽办法避免线程的同步。如果实在难以避免，就减少线程同步的排队时间。

避免线程同步

该怎么避免线程同步呢？

对应上述的同步场景所需的三个条件，我们只要打破其中的任何一个条件，就不需要线程同步了：

使用单线程；

不关心共享资源的变化；

没有改变共享资源的行为。

举个例子吧，下面的这段代码用于表示在不同的语言环境下，该怎么打招呼。在汉语环境下，我们说“你好”；在英语环境下，我们说"Hello"。

如果只有一个线程，这段代码就没有问题。但是，如果有两个线程，一个线程读，一个线程写，就会出现竞争状况，返回不匹配的语言环境和问候语。

class HelloWords {

    private String language = "English";

    private String greeting = "Hello";

    void setLanguage(String language) {

        this.language = language;

    }

    void setGreeting(String greeting) {

        this.greeting = greeting;

    }

    String getLanguage() {

        return language;

    }

    String getGreeting() {

        return greeting ;

    }

}

比如说，如果两个线程的执行顺序是：

线程 1 执行 getLanguage()，得到返回值是英语的语言环境；

线程 2 执行 setGreeting()，把问候语设置为汉语环境的“你好”；

线程 1 执行 getGreeting()，得到返回值是问候语“你好”。

那么，按照线程 1 得到的结果，在英语环境下，我们打招呼用“你好”。这可差的远了。

怎么改变这种状况呢？ 其中一种方法，就是要把变量，变成像鱼缸里的碎石、水草这样的不可变的东西。不可变（immutable），放在软件环境里，指的就是一旦实例化，就不再改变。思路就是把变化放在出品之前。做到这一点的利器，就是 Java 的关键字“final”。

class HelloWords {

    private final String language;

    private final String greeting;

    HelloWords(String language, String greeting) {

        this.language = language;

        this.greeting = greeting;

    }

    String getLanguage() {

        return language;

    }

    String getGreeting() {

        return greeting ;

    }

}

使用了限定词“final”的类变量，只能被赋值一次，而且只能在实例化之前被赋值。这样的变量，就是不可变的量。如果一个类的所有的变量，都是不可变的，那么这个类也是不可变的。

不使用限定词“final”，能不能达到不可变的效果呢？如果我们把上面代码中的限定词“final”删除掉，代码实现的细节依然保证这两个变量具有不可变的效果。 只是，如果代码再长一点，方法再多一点，我们可能会不经意地修改这两个变量，使得这个类又重新面临线程同步问题。

所以，我们要养成一个习惯，看到声明的变量，就要琢磨，这个变量能不能声明成不可变的量？现有的代码设计，这个变量如果不是不可变的，我们也要琢磨，有没有办法修改接口设计或者实现代码，把它改成不可变的量？设计一个类时，要优先考虑，这个类是不是可以设计成不可变的类？这样就可以避免很多不必要的线程同步，让代码的效率更高，接口更容易使用。

如果这是一个开放的不可变的类，我们要在接口规范里声明这个类是不可变的。这样调用者就不用考虑多线程安全的问题。没有声明多线程安全，或者不可变的接口，都不能当作线程安全的接口使用。

这是一个即便是资深的 Java 专家，也容易忽视的用法。希望你学会使用 final 限定词，让设计的接口又好用，又有效率。

减少线程同步时间

减少线程同步的排队时间，换一个说法，就是减少同步线程的阻塞时间。

比如说吧，如果小鱼吐泡泡需要同步，吐泡泡的时间越短越好。如果把吐泡泡的整个过程分成三步，吸气、吐泡、呼气，每一步用时 1/3 秒。如果排队轮到一条小鱼儿吐泡，它要完成所有三步，才轮到小一条小鱼，那么这个阻塞时间就是 1 秒。如果轮到这个小鱼儿吐泡时，它已经完成了吸气的动作，吐完泡就让给下一条等待吐泡的小鱼，离开队伍后再呼气，那么这个阻塞时间就是 1/3 秒。

在阻塞的这段时间里，做的事情越少，阻塞时间一般就会越短。

这个小鱼吐泡泡的过程，可以表示成如下的代码：

从这段代码里，我们可以看到，减少阻塞时间的一个办法，就是只同步和共享资源变化相关的逻辑。引起共享资源变化的事前准备以及善后处理，属于线程内部变化，不需要同步处理。

在设计接口或者实现代码时，有一项很重要的一个工作，就是反复考虑在多线程环境下，怎么做才能让线程同步的阻塞时间最小。这是一个很值得花费时间去琢磨的地方。比如上面小鱼吐泡泡的微小改进，效率就提高了三倍。

小结

今天，我们主要讨论线程同步的基本概念以及超越线程同步的技巧。由于线程同步对效率的损害，我们使用线程同步的最高技巧，就是不使用线程同步。如果做不到这一点，在线程同步的处理时间内，做的事情越少越好。

线程同步本身非常复杂，它相关的技术也很繁杂。这方面可以参考的书籍和文章也很多。我们不在这里讨论这些同步的技术了。

欢迎你在留言区，讨论这些技术，分享你使用这些技术的心得体会，我们一起来学习、精进。

一起来动手

下面的这段代码，摘录自 OpenJDK，我们上次使用过。上一次，我们讨论了它的接口设计问题。

代码中 Signature 这个类，不是一个天然的多线程安全的类，它的 setParameter()，initSign()，update() 这些方法，都可以改变实例的状态。

如果要你去实现一个多线程安全的子类，你会怎么办？

如果要你重新设计这个类，包括拆分成几个类，你有没有办法把它设计成一个天然的多线程安全的类？

你试试看，能不能解决这些问题。欢迎你把发现的问题，解决的办法，以及优化的接口公布在讨论区，也可以写一下你的解决问题的思路。Signature 这个类，是一个有着二十多年历史的，被广泛使用的 Java 核心类。说不定，你可以为 OpenJDK 社区，提供一个有价值的参考意见或者改进方案。

/*

 * Copyright (c) 1996, 2018, Oracle and/or its affiliates. All rights reserved.

 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.

 *

 * <snipped>

 */

package java.security;

import java.security.InvalidAlgorithmParameterException;

import java.security.InvalidKeyException;

import java.security.PrivateKey;

import java.security.PublicKey;

import java.security.SignatureException;

import java.security.SignatureSpi;

import java.security.spec.AlgorithmParameterSpec;

/**

 * The Signature class is used to provide applications the functionality

 * of a digital signature algorithm. Digital signatures are used for

 * authentication and integrity assurance of digital data.

 *

 * <snipped>

 *

 * @since 1.1

 */

public abstract class Signature extends SignatureSpi {

    // snipped

    /**

     * Initializes this signature engine with the specified parameter set.

     *

     * @param params the parameters

     *

     * @exception InvalidAlgorithmParameterException if the given parameters

     * are inappropriate for this signature engine

     *

     * @see #getParameters

     */

    public final void setParameter(AlgorithmParameterSpec params)

            throws InvalidAlgorithmParameterException {

        // snipped

    }

    /**

     * Initializes this object for verification. If this method is called

     * again with a different argument, it negates the effect

     * of this call.

     *

     * @param publicKey the public key of the identity whose signature is

     * going to be verified.

     *

     * @exception InvalidKeyException if the key is invalid.

     */

    public final void initVerify(PublicKey publicKey)

            throws InvalidKeyException {

        // snipped

    }

    /**

     * Initialize this object for signing. If this method is called

     * again with a different argument, it negates the effect

     * of this call.

     *

     * @param privateKey the private key of the identity whose signature

     * is going to be generated.

     *

     * @exception InvalidKeyException if the key is invalid.

     */

    public final void initSign(PrivateKey privateKey)

            throws InvalidKeyException {

        // snipped

    }

    /**

     * Updates the data to be signed or verified, using the specified

     * array of bytes.

     *

     * @param data the byte array to use for the update.

     *

     * @exception SignatureException if this signature object is not

     * initialized properly.

     */

    public final void update(byte[] data) throws SignatureException {

        // snipped

    }

    /**

     * Returns the signature bytes of all the data updated.

     * The format of the signature depends on the underlying

     * signature scheme.

     *

     * <p>A call to this method resets this signature object to the state

     * it was in when previously initialized for signing via a

     * call to {@code initSign(PrivateKey)}. That is, the object is

     * reset and available to generate another signature from the same

     * signer, if desired, via new calls to {@code update} and

     * {@code sign}.

     *

     * @return the signature bytes of the signing operation's result.

     *

     * @exception SignatureException if this signature object is not

     * initialized properly or if this signature algorithm is unable to

     * process the input data provided.

     */

    public final byte[] sign() throws SignatureException {

        // snipped

    }

    /**

     * Verifies the passed-in signature.

     *

     * <p>A call to this method resets this signature object to the state

     * it was in when previously initialized for verification via a

     * call to {@code initVerify(PublicKey)}. That is, the object is

     * reset and available to verify another signature from the identity

     * whose public key was specified in the call to {@code initVerify}.

     *

     * @param signature the signature bytes to be verified.

     *

     * @return true if the signature was verified, false if not.

     *

     * @exception SignatureException if this signature object is not

     * initialized properly, the passed-in signature is improperly

     * encoded or of the wrong type, if this signature algorithm is unable to

     * process the input data provided, etc.

     */

    public final boolean verify(byte[] signature) throws SignatureException {

        // snipped

    }

}

23 | 怎么减少内存使用，减轻内存管理负担？

管理内存，不管是什么编程语言，向来都是一个难题。Java 语言能够长期领先的一个重要原因，就是它拥有强大的内存管理能力，并且这种能力还在不断地进化。然而，只依靠 Java 内在的内存管理能力，是远远不够的。

2018 年 9 月，亚马逊向 OpenJDK 社区提交了一个改进请求。这个改进涉及到一个问题，如果一个服务的缓存数量巨大，比如说有 10 万个连接会话，Java 的垃圾处理器要停滞几分钟，才能清理完这么巨大的缓存。而这几分钟的停滞，是不可忍受的事故。

这是一个值得我们关注的细节。缓存的本意，就是为了提高效率。然而，拥有过多的用户，过多的缓存，反而会让效率变低。

随着大数据、云计算以及物联网的不断演进，很多技术都面临着巨大的挑战。七八年前（2010 年左右），能解决 C10K（同时处理 1 万个用户连接）问题，感觉就可以高枕无忧了。现在有不少应用，需要开始考虑 C10M（同时处理 1 千万个用户连接）问题，甚至是更多的用户连接，以便满足用户需求。很多以前不用担心的问题，也会冒出来算旧账。

要想让内存使用得更有效率，我们还需要掌握一些成熟的实践经验。

使用更少的内存

提高内存使用最有效率的办法，就是使用更少的内存。这听起来像是废话，却也是最简单直接、最有用的办法。减少内存的使用，意味着更少的内存分配、更少的内存填充、更少的内存释放、更轻量的垃圾回收。内存的使用减少一倍，代码的效率会成倍地提升，这不是简单的线性关系。

减少内存的使用，办法有且只有两个。第一个办法是减少实例的数量。第二个办法是减小实例的尺寸。

减少实例数量

还记得以前我们用到的，在不同的语言环境下，该怎么打招呼的代码吗？上一次，我们把它改成了不可变的类，避免了线程同步的问题。我把这段代码重新抄录在下面。

这段代码还有个问题，就是内存使用不够友好。对于汉语环境来说，打招呼用“你好”。如果使用上面的设计，那么每次使用汉语环境，调用构造方法，都产生一个不同的实例对象。

如果只实例化一次，当然没有什么问题。如果要实例化 10 次，100 次，1000 次，10000 次，而且每个实例都是固定的汉语加“你好”，这就是很大的浪费了。内存的使用，随着实例的数量线性增长，100 个实例，就要使用 100 倍的内存。即便实例的产生和废弃都非常迅速，在巨大的实例数量面前，Java 的垃圾处理器也会有很大的压力。

HelloWords helloWords = new HelloWords("Chinese", "Ni Hao");

......

System.out.prinyln(

        "The hello words in Chinese: " + helloWords.getGreeting());

如果一种语言环境的打招呼的办法是固定不变的，而且语言环境的数量有限的话，我们就只有必要使用一个实例。

如果有了这个意识的话，那么对于这个打招呼的代码，我们就可以很自然地想到使用枚举类型，把它改进成下面的样子。

使用了枚举类型后，每一种语言就只有一个实例了。不管使用多少次，对内存的影响，对 Java 的垃圾处理器的影响，几乎可以忽略不计。

对于数量有限的对象，我们应该优先考虑使用枚举类型，比如交通标志，国家名称等等。其实，枚举类型就是一种常用的数据静态化的范例。我们还会在后面讨论其他类似的数据静态化的处理方式。

避免不必要的实例

Java 语言里，有一些历史遗留的接口设计问题，会无意中导致不必要的实例。我们下面来看看两个例子。

第一个例子是应用程序使用了不必要的构造函数。比如，使用 String 的构造函数实例化一串字符。

上面的反面实例，每次调用都会产生一个实例对象，而这个实例对象需要交给 Java 垃圾处理器管理。事实上，由于 String 是一个不可变的类，每次调用产生的实例没有任何的区别。如果这样的代码经常使用，比如说被调用了十万次，就会有十万个实例对象产生，Java 垃圾处理器就需要管理十万个实例。

这是一个很大的，不必要的开销。上面的两个正面案例，使用单实例的编码习惯，无论这段代码被调用了多少次，在 Java 的运行环境下，都只有一个实例。而且，相同的字符串，即使位于不同的代码空间里，在同一 Java 的运行环境下，也都只有一个实例。

String 类的这个构造函数，是一个接口设计的历史遗留问题，价值小，问题多。Java 基础类库里，还有一些类似的历史遗留问题，特别是原始数据类型（primitive type）对应的类。我们要避免使用它们的构造方法，甚至避免使用这些类。

幸运的是，这些原始数据类型对应类的构造方法，从 Java 9 开始，就已经被废弃了。但是这些方法依然存在，这些类依然存在。不论在哪里，如果你看到还有代码使用原始数据类型的构造函数，都可以提交一个问题报告。这样的更改，付出少，收益大。

避免使用原始数据类

通过上面的讨论，我们可以理解，为什么要避免使用原始数据类型的构造方法。可是为什么还要避免使用原始数据类呢？这里涉及到 Java 原始数据类型的自动装箱（boxing）与拆箱（unboxing）的类型转换。

比如说，下面的代码，就涉及到一个装箱的过程。整数 0 和 2 都要先被转换成一个 Long 类的实例，然后才执行赋值操作。

这个装箱的过程，就产生了不必要的实例。如果这样的转换数量巨大，就会有明显的性能影响。

使用单实例模式

由于 Java 内在的单实例模式，我们可以很方便地使用 Java 的原始数据类型，而不用担心实例数量的增长。对于复合的类，我们也可以自己设计单实例模式，从而减少多实例带来的不必要的开销。

比如，下面的代码，就是一个单实例模式例子。

单实例的设计方法有很多种方式，也有很多小细节需要处理，限于篇幅，我们就不在这里讨论这些技术了。欢迎你在讨论区分享你的经验和想法，来丰富这一部分的内容。

减小实例的尺寸

减少内存的使用还有另外一个办法，就是减小实例的尺寸。所谓减少实例的尺寸，就是减少这个实例占用的内存空间。这个空间，不仅包括实例的变量标识符占用的空间，还包括标识符所包含对象的占用空间。

比如下面的例子中，使用了 String 构造方法的变量，就独占了包括“Java”这四个字符的 String 实例空间。而使用了字符串赋值的变量，就和其他代码一起共享“Java”这四个字符的缺省的实例空间。

在减少变量数量这一方面，我们一般没有太多的自由空间。那么，在减少实例尺寸方面，我们能有所作为的，就是在标识符所指对象方面多费心思。简单地说，就是减少标识符所引用对象的尺寸。办法也有两个，第一个是尽量减少独占的空间；第二个是尽量使用共享的实例。

尽可能多地共享资源，这是一条提高效率的基本原则。在编写代码时，如果能够引用，就坚决不要拷贝；如果能够复用，就坚决不要新创。当然，资源的共享，除了上一次提到的线程同步问题，还有一个资源的维护问题。一个资源，如果不需要维护，那就太理想了。

有两类理想的共享资源，一类是一成不变（immutable）的资源，另一类是禁止修改（unmodifiable）的资源。

不可变的类

上一次，在讨论线程同步问题时，我们也讨论了不可变的类。由于不可变的类一旦实例化，就不再变化，我们可以放心地在不同的地方使用它的引用，而不用担心任何状态变化的问题。

无法修改的对象

还有一类对象，虽然不是不可变的类的实例，但是它的修改方法被禁止了。当我们使用这些对象的代码时，没有办法对它做出任何修改。这样，这些对象就有了和不可变的实例一样的优点，可以放心地引用。

从 Java 8 开始，Java 核心类库通过 Collections 类提供了一系列的生成不可更改的集合的方法。这些方法，极大地减轻了集合的共享和维护问题。

比如，下面的这个方法，就返回了一个不可更改的列表对象。这个对象，可以赋值给多个标识符，不需要列表的拷贝，也不用担心列表的维护问题。在合适的场景，考虑使用好不可更改的集合，是一个值得推荐的编码习惯。

public List<byte[]> getStatusResponses() {

    List<byte[]> responses = new ArrayList<>();

    // snipped

    return Collections.unmodifiableList(responses);

}

毋庸置疑的是，我们不能总是使用不变的共享资源。可以变化的共享资源也有难以替代的作用。后面的章节，我们再接着讨论使用可变的共享资源的技巧。

小结

今天，我们主要讨论了怎么减少内存使用。基本的方向有两个，一个是减少实例数量，另一个是减少实例的尺寸。这两个方向看着都很简单，我们在编码时，要养成考虑这两个因素的习惯。想得多了，用得多了，你编写的代码对内存就会越来越友好，设计的接口也会越来越好用。

应用程序方面，内存使用的优化技术和实践有很多。欢迎你在留言区，讨论这些技术和经验，分享你使用这些技术的心得体会，我们一起来学习、精进。

一起来动手

我上面使用的一个例子，写得确实很丑陋。不过，当我想到，可以把它当作一个练手题的时候，我就稍微宽心了点。

你琢磨琢磨下面的这段代码，看看能不能实现 getInstance() 这个方法。该怎么修改，才能让这个方法更有效率？

另外，你能想明白为什么构造方法会设计成私有方法吗？变量为什么没有使用 private 关键字？这些小细节很有意思，如果你已经清楚了这些细节背后的原因，也欢迎你分享在讨论区。

欢迎你在留言区讨论上面的问题，也可以把这篇文章分享给你的朋友或者同事，我们一起来看看这个有点丑的代码，可以变得有多美。

24 | 黑白灰，理解延迟分配的两面性

上一次，我们讨论了减少内存使用的两个大方向，减少实例数量和减少实例的尺寸。如果我们把时间的因素考虑在内，还有一些重要的技术，可以用来减少运行时的实例数量。其中，延迟分配是一个重要的思路。

延迟分配

在前面讨论怎么写声明的时候，为了避免初始化的遗漏或者不必要的代码重复，我们一般建议“声明时就初始化”。但是，如果初始化涉及的计算量比较大，占用的资源比较多或者占用的时间比较长，声明时就初始化的方案可能会占用不必要的资源，甚至成为软件的一个潜在安全问题。

这时候，我们就需要考虑延迟分配的方案了。也就是说，不到需要时候，不占用不必要的资源。

下面，我们通过一个例子来了解下什么是延迟分配，以及延迟分配的好处。

在 Java 核心类中，ArrayList 是一个可调整大小的列表，内部实现使用数组存储数据。它的优点是列表大小可调整，数组结构紧凑。列表大小可以预先确定，并且在大小不经常变化的情况下，ArrayList 要比 LinkedList 节省空间，所以是一个优先选项。

但是，一旦列表大小不能确定，或者列表大小经常变化，ArrayList 的内部数组就需要调整大小，这就需要内部分配新数组，废弃旧数组，并且把旧数组的数据拷贝到新数组。这时候，ArrayList 就不是一个好的选择了。

在 JDK 7 中，ArrayList 的实现可以用下面的一小段伪代码体现。你可以从代码中体会下内部数组调整带来的“酸辣”。

package java.util;

public class ArrayList<E> extends AbstractList<E>

        implements List<E>, RandomAccess, Cloneable, java.io.Serializable {

    private transient Object[] elementData;

    private int size;

    public ArrayList() {

        this.elementData = new Object[10];

    }

    @Override

    public boolean add(E e) {

        ensureCapacity(size + 1);

        elementData[size++] = e;

        return true;

    }

    private void ensureCapacity(int minCapacity) {

        int oldCapacity = elementData.length;

        if (minCapacity > oldCapacity) {

            Object oldData[] = elementData;

            int newCapacity = (oldCapacity * 3) / 2 + 1;

            if (newCapacity < minCapacity) {

                newCapacity = minCapacity;

            }

            elementData = Arrays.copyOf(elementData, newCapacity);

        }

    }

}

这段代码里的缺省构造方法，分配了一个可以容纳 10 个对象的数组，不管这个大小合不合适，数组需不需要。这看似不起眼的大小为 10 的数组，在高频率的使用环境下，也是一个不小的负担。

在 JDK 8 中，ArrayList 的实现做了一个小变动。这个小变动，可以用下面的一小段伪代码体现。

package java.util;

public class ArrayList<E> extends AbstractList<E>

        implements List<E>, RandomAccess, Cloneable, java.io.Serializable {

    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

    private transient Object[] elementData;

    private int size;

    public ArrayList() {

        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;

    }

    // snipped

}

改动后的缺省构造方法，不再分配内部数组，而是使用了一个空数组。要等到真正需要存储数据的时候，才为这个数组分配空间。这就是所谓的延迟初始化。

这么小的变动带来的好处到底有多大呢？这个改动的报告记录了一个性能测试结果，改动后的内存的使用减少了 13%，平均响应时间提高了 16%。

你是不是很吃惊这样的结果？这个小改动，看起来真的不起眼。代码的优化对于性能的影响，有时候真的是付出少、收益大。

从 ArrayList 的上面的改动，我们能够学习到什么东西呢？我学到的最重要的东西是，对于使用频率高的类的实现，微小的性能改进，都可以带来巨大的实用价值。

在前面讨论怎么写声明的时候，我们讨论到了“局部变量需要时再声明”这条原则。局部变量标识符的声明应该和它的使用尽可能地靠近。这样的规范，除了阅读方面的便利之外，还有效率方面的考虑。局部变量占用的资源，也应该需要时再分配，资源的分配和它的使用也要尽可能地靠近。

延迟初始化

延迟分配的思路，就是用到声明时再初始化，这就是延迟初始化。换句话说，不到需要的时候，就不进行初始化。

下面的这个例子，是我们经常使用的初始化方案，声明时就初始化。

public class CodingExample {

    private final Map<String, String> helloWordsMap = new HashMap<>();

    private void setHelloWords(String language, String greeting) {

        helloWordsMap.put(language, greeting);

    }

    // snipped

}

声明时就初始化的好处是简单、直接、代码清晰、容易维护。但是，如果初始化占用的资源比较多或者占用的时间比较长，这个方案就有可能带来一些负面影响。我们就要慎重考虑了。

在 JDK 11 之前的 Java 版本中，按照 HashMap 类构造方法的内部实现，初始化的实例变量 helloWordsMap，要缺省地分配一个可以容纳 16 个对象的数组。这个缺省的数组尺寸，比 JDK 7 中的 ArrayList 缺省数组还要大。如果后来的方法使用不到这个实例变量，这个资源分配就完全浪费了；如果这个实例变量没有及时使用，这个资源的占用时间就拉长了。

这个时候是不是可以考虑延迟初始化？下面的例子，就是一种延迟初始化的实现方法。

public class CodingExample {

    private Map<String, String> helloWordsMap;

    private void setHelloWords(String language, String greeting) {

        if (helloWordsMap == null) {

            helloWordsMap = new HashMap<>();

        }

        helloWordsMap.put(language, greeting);

    }

    // snipped

}

上面的例子中，实例变量 helloWordsMap 只有需要时才初始化。这的确可以避免内存资源的浪费，但代价是要使用更多的 CPU。检查实例变量是否已经能初始化，需要 CPU 的额外开销。这是一个内存和 CPU 效率的妥协与竞争。

而且，除非是静态变量，否则使用延迟初始化，一般也意味着放弃了使用不可变的类可能性。这就需要考虑多线程安全的问题。上面例子的实现，就不是多线程安全的。对于多线程环境下的计算，初始化时需要的线程同步也是一个不小的开销。

比如下面的代码，就是一个常见的解决延迟初始化的线程同步问题的模式。这个模式的效率，还算不错。但是里面的很多小细节都忽视不得，看起来都很头疼。我每次看到这样的模式，即便明白这样做的必要性，也恨不得先休息半天，再来啃这块硬骨头。

public class CodingExample {

    private volatile Map<String, String> helloWordsMap;

    private void setHelloWords(String language, String greeting) {

        Map<String, String> temporaryMap = helloWordsMap;

        if (temporaryMap == null) {    // 1st check (no locking)

            synchronized (this) {

                temporaryMap = helloWordsMap;

                if (temporaryMap == null) {    // 2nd check (locking)

                    temporaryMap = new ConcurrentHashMap<>();

                    helloWordsMap = temporaryMap;

                }

            }

        }

        temporaryMap.put(language, greeting);

    }

    // snipped

}

延迟初始化到底好不好，要取决于具体的使用场景。一般情况下，由于规范性带来的明显优势，我们优先使用“声明时就初始化”这个方案。

所以，我们要再一次强调，只有初始化占用的资源比较多或者占用的时间比较长的时候，我们才开始考虑其他的方案。复杂的方法，只有必要时才使用。

※注：从 JDK 11 开始，HashMap 的实现做了改进，缺省的构造不再分配实质性的数组。以后我们写代码时，可以省点心了。

小结

今天，我们主要讨论了怎么通过延迟分配减少实例数量，从而降低内存使用。

对于局部变量，我们应该坚持“需要时再声明，需要时再分配”的原则。

对于类的变量，我们依然应该优先考虑“声明时就初始化”的方案。如果初始化涉及的计算量比较大，占用的资源比较多或者占用的时间比较长，我们可以根据具体情况，具体分析，采用延迟初始化是否可以提高效率，然后再决定使用这种方案是否划算。

一起来动手

我上面写的延迟初始化的同步的代码，其实是一个很固定的模式。对于 Java 初学者来说，理解这段代码可能需要费点功夫。评审代码的时候，每次遇到这个模式，我都要小心再小心，谨慎再谨慎，生怕漏掉了某个细节。

借着这个机会，我们一起来把这个模式理解透，搞清楚这段代码里每一个变量、每一个关键词扮演的角色。以后遇到它，我们也许可以和它把手言欢。

我把这段代码重新抄写在了下面，关键的地方加了颜色。我们在讨论区讨论下面这些问题：

helloWordsMap 变量为什么使用 volatile 限定词？

为什么要 temporaryMap 变量？

temporaryMap 变量为什么要两次设置为 helloWordsMap？

为什么要检查两次 temporaryMap 的值不等于空？

synchronized 为什么用在第一次检查之后？

为什么使用 ConcurrentHashMap 而不是 HashMap？

为什么使用 temporaryMap.put() 而不是 helloWordsMap.put()？

如果你有更多的问题，请公布在讨论区，也可以和你的朋友一起讨论。弄清楚了这些问题，我相信我们可以对 Java 语言的理解更深入一步。

25 | 使用有序的代码，调动异步的事件

同步和异步，是两个差距很大的编程模型。同步，就是很多事情一步一步地做，做完上一件，才能做下一件。异步，就是做事情不需要一步一步的，多件事情，可以独立地做。

比如一个有小鸟的笼子，如果打开笼门，一个一个地放飞小鸟，就是同步。如果拆了整个鸟笼，让小鸟随便飞，爱怎么飞就怎么飞，这就是异步。

为什么需要异步编程？

如果我们观察身边的事物，现实中有很多事情是以异步的方式运营的。我们挤地铁的时候，从来都是好几个人一起挤进去的。当我们正在挤地铁时，外面的风照旧吹，雨照旧下，天坛的大爷大妈们正在秀着各种绝活。没有任何事情会因为我们正在挤地铁就停止活动，等我们挤完地铁再恢复运转。

可是，要是说到其中的任何一个人，就不能同时做两件事情了。在挤地铁的时候，就不能在天坛秀绝活。我们写的程序也是这样，先执行第一行，再执行第二行。哪怕第二行再怎么费周折，第三行代码也要等着。

第二行代码可能需要执行大量的计算，需要很多的 CPU；也可能需要大量的传输，占用 I/O 通道。可是，它不一定会把所有的计算机资源都占用了。

如果第二行代码占用了 I/O，我们能不能把多余的 CPU 用起来？如果第二行代码占用了 CPU，我们能不能把空闲的 I/O 用起来? 也就是说，能不能把计算机整体资源更有效地使用起来？

该怎么办呢？想想家里的一把手做事的风格吧。

“你去小区菜店买瓶酱油，买回来我们就做饭。”第一道指令发布完毕。

“你把垃圾扔出去吧，都有馊味了。”第二道指令发布完毕。

“我赶快收拾下屋子，有两天没打扫了。”第三道指令发布完毕。

尽管每一道指令都很简短，但是每件事情都交代得很清楚。然后，每个人都忙碌了起来，各忙各的事情。效率也就比一件事情做完再做下一件高出很多。

如果我们把三行代码换成三道指令。第三行代码虽然依然要等待，但只需等待第二道指令发布完成，而不是第二道指令背后的事情完成。等待的时间变短，效率也就提升了。

我想，这就是异步编程的背后的驱动力量，以及基本的处理逻辑。为了更有效地利用计算资源，我们使用有序的代码，调动起独立的事件。

从过程到事件

异步编程和我们熟悉的同步编程最大的区别，就是它要我们从事件的角度来编写和理解代码。就像我举的生活中的一些例子，说的做的多是“事情”。由于我们一般先学习的是对象、方法和过程这些模型，已经建立了一定的思考模式，对于事件驱动的编程模型可能会有点不习惯。事实上，熟悉了异步编程的思路，你会发现异步编程很贴近我们的生活模式。

在下面的例子，我使用了 JDK 11 新添加的 HttpClient 接口。最后一个语句，就是一个异步模式。这个语句的意思，就是交代一件事情：“访问 www.example.com，并且把响应数据打印到标准输出上。”需要注意的是，这个语句就是发布了这条指令。指令发布完，这个语句的任务就完成了，就可以执行下一个语句了，不需要等待指令交代的任务完成。

// Create an HTTP client that prefers HTTP/2.

HttpClient httpClient = HttpClient.newBuilder()

               .version(Version.HTTP_2)

               .build();

// Create a HTTP request.

HttpRequest httpRequest = HttpRequest.newBuilder()

               .uri(URI.create("https://www.example.com/"))

               .build();

// Send the request and set the HTTP response handler

httpClient.sendAsync(httpRequest, BodyHandlers.ofString())

               .thenApply(HttpResponse::body)

               .thenAccept(System.out::println);

// next action

我们可以对比一下传统的代码。下面的代码使用了 JDK 10 以前的 HttpURLConnection 接口。完成的是同样的任务。不同的是，下一件事情的代码需要等待上一件事情的完成，才能执行。也就是说，建立网络连接之后，才能执行读取响应数据的代码。

// Open the connection

URL url = new URL("https://www.example.com/");

HttpsURLConnection urlc = (HttpsURLConnection)url.openConnection();

// Read the response

try (InputStream is = urlc.getInputStream()) {

    while (is.read() != -1) {    // read to EOF

        // dump the response

        // snipped

    }

}

// next action

使用 HttpURLConnection 接口的代码，无论是连接过程，还是响应数据的读取过程，都依赖于网络环境，而不仅仅是计算机的环境。如果网络环境的响应时间是三秒，那么上面的代码就要阻塞三秒，无法执行下一步操作。

而 HttpClient 接口的代码，指令发布完，就可以执行下一步操作了。这个指令的执行时间，一般是毫秒以下的数量级别。

如果我们不考虑其他因素的影响，那么上面的两个例子中，异步模式在网络阻塞期间，能够更好地利用其他的计算资源，从而提高整体的效率。

异步是怎么实现的？

你是不是有个疑问，指令交代的任务是怎么完成的？异步的实现，依赖于底层的硬件和操作系统；如果操作系统不支持，异步也可以通过线程来模拟。

即便是只能通过线程来模拟，异步编程也简化了线程管理的难度。甚至能够把线程管理变透明，隐藏起来。比如我们上面使用的 HttpClient 接口的代码，就没有线程的影子，看起来像一个单线程程序。

异步编程对性能的爆炸性的提升来自于硬件和操作系统对异步的支持。

比如说，早期传统的套接字编程，应用程序需要等待下一个连接的到来，然后等待连接数据的传输……这些等待，都需要耗费很多资源。这些被占用的资源，在连接和数据到来之前，都是没有被充分利用的资源。

如果操作系统能够主动告诉应用程序，什么时候有一个连接请求，这个连接里什么时候有数据。应用程序就可以在连接和数据到来之后，再分配资源进行处理。操作系统在合适的时间，遇到触发事件，主动调用设置的应用程序，执行相关的操作。这就是操作系统对异步 I/O 的支持。

比如说，如果一个简单的服务就返回一个"Hello, World!"，它能够同时接受多少用户访问呢？

如果使用传统的一个线程一个用户的模式，这个用户数量完全取决于线程的效率和容量。随着用户数的增加，线程数量也线性增加，线程管理也越来越复杂，线程的效率也加速下降，线程处理能力决定了系统最大可承载的用户数。

如果使用异步 I/O，每一个 CPU 分派一个线程就足以应付所有的连接。这时候，连接的效率就主要取决于硬件和操作系统的能力了。

根据常见的数据，这种效率的提升通常可以达到几百倍。

下面的例子，就是一个简单异步服务的框架。你可以比较一下，它和传统服务器代码的差异。

final AsynchronousServerSocketChannel listener =

        AsynchronousServerSocketChannel

                .open()

                .bind(new InetSocketAddress("localhost", 6789));

listener.accept(null, new CompletionHandler<AsynchronousSocketChannel,Void>() {

    @Override

    public void completed(AsynchronousSocketChannel ch, Void att) {

        // accept the next connection, non-blocking

        listener.accept(null, this);

        // handle this connection

        handle(ch);

    }

    @Override

    public void failed(Throwable exc, Void att) {

        // snipped

    }

});

零拷贝，进一步的性能提升

异步编程的性能并没有止步于异步 I/O，它还有提升的空间。

前面，我们讨论了减少内存使用的两个大方向，减少实例数量和减少实例的尺寸。使用共享内存，减少内存拷贝，甚至是零拷贝，可以减少 CPU 消耗，也是减少实例数量和减少实例尺寸的一个办法。

下面的例子中，我们使用了 ByteBuffer.allocateDirect() 方法分配了一块内存空间。这个方法的实现，会尽最大的努力，减少中间环节的内存拷贝，把套接字的缓存数据，直接拷贝到应用程序操作的内存空间里。这样，就减少了内存的占用、分配、拷贝和废弃，提高了内存使用的效率。

listener.accept(null, new CompletionHandler<AsynchronousSocketChannel,Void>() {

    @Override

    public void completed(AsynchronousSocketChannel ch, Void att) {

        // accept the next connection, non-blocking

        listener.accept(null, this);

        // handle this connection

        ByteBuffer bbr = ByteBuffer.allocateDirect(1024);

        ch.read(bbr, null, new CompletionHandler<Integer, Object>() {

				@Override

				public void completed(Integer result, Object attachment) {

                    // snipped

                }

				@Override

				public void failed(Throwable exc, Object attachment) {

                    // snipped

				}

			});

    }

    @Override

    public void failed(Throwable exc, Void att) {

        // snipped

    }

});

需要注意的是，这种方式分配的内存，分配和废弃的效率一般比常规的 Java 内存分配差一些。所以，只建议用在数据量比较大，存活时间比较长的情况下，比如网络连接的 I/O。而且，一个连接最多只用一个读、一个写两块空间。这样，才能把它的效率充分发挥出来。

小结

今天，我们主要讨论了异步的一些基本概念，以及异步对于效率提升的作用。异步编程，常见的模型是事件驱动的。我们通过使用有序的代码，调动独立的事件，来更有效地利用计算资源。

一起来动手

这一次的几个例子，大致提供了异步连接编程的一个基本框架。你可以试着把这些代码丰富起来，组成一个可以运行的客户端和服务端。客户端使用 HttpClient 接口发起 HTTP 连接；服务端使用异步的模式，把客户端的 HTTP 请求数据原封不动发回去。

下一篇文章，我会介绍一个简单的测试代码性能的工具。如果有兴趣，你可以继续测试下你编写的代码的性能，是不是比同步的编程模式有所提高。

欢迎你把你的代码公布在讨论区，我们一起来学习，一起来进步。如果你想和朋友或者同事比试一下，不妨把这篇文章分享给他们，互相切磋。

26 | 有哪些招惹麻烦的性能陷阱？

前面，我们讨论了改善代码性能的最基本的办法。接下来，我们讨论一些最佳实践，让我们先从一些容易被忽略的性能陷阱开始。

使用性能测试工具

今天我们的讲解需要用到一个工具，它就是 JMH。JMH 是为 Java 语言或者其他基于 JVM 的编程语言设计的一个基准测试工具。这一节，我们会使用这个工具来分析一些性能的陷阱。这里我们简单地介绍下，这个工具该怎么使用。

第一步，使用 Maven 工具建立一个基准测试项目（需要使用 Maven 工具）：

$ mvn archetype:generate \

          -DinteractiveMode=false \

          -DarchetypeGroupId=org.openjdk.jmh \

          -DarchetypeArtifactId=jmh-java-benchmark-archetype \

          -DgroupId=com.example \

          -DartifactId=myJmh \

          -Dversion=1.0

这个命令行，会生成一个 myJmh 的工程目录，和一个基准测试模板文件（myJmh/src/main/java/com/example/MyBenchmark.java）。通过更改这个测试模板，就可以得到你想要的基准测试了。

比如，你可以使用后面我们用到的基准测试代码，替换掉模板中的基准测试方法（measureStringApend）。

package com.example;

import org.openjdk.jmh.annotations.Benchmark;

public class MyBenchmark {

    @Benchmark

    public String measureStringApend() {

        String targetString = "";

        for (int i = 0; i < 10000; i++) {

            targetString += "hello";

        }

        return targetString;

    }

}

第二步，编译基准测试：

$ cd myJmh

$ mvn clean install

第三步，运行你的基准测试：

$ cd myJmh

$ Java -jar target/benchmarks.jar

稍微等待，基准测试结果就出来了。我们需要关注的是"Score"这一栏，它表示的是每秒钟可以执行的基准测试方法的次数。

Benchmark                Mode  Cnt        Score          Error  Units

MyBenchmark.testMethod  thrpt   25        35.945 ▒       0.694  ops/s

这是 JMH 工具基本的使用流程，有关这个工具更多的选项和更详细的使用，需要你参考 JMH 的相关文档。

下面，我们通过字符串连接操作和哈希值的例子，来谈论一下这个工具要怎么使用，以及对应的性能问题。同时，我们再看看其他影响性能的一些小陷阱，比如内存的泄露、未关闭的资源和遗漏的 hashCode。

字符串的操作

在 Java 的核心类库里，有三个字符串操作的类，分别问 String、StringBuilder 和 StringBuffer。通过下面的基准测试，我们来了解下这三种不同的字符串操作的性能差异。为了方便，我把 JMH 测试的数据，标注在每个基准测试的方法注释里了。

    // JMH throughput benchmark: about 32 operations per second

    @Benchmark

    public String measureStringApend() {

        String targetString = "";

        for (int i = 0; i < 10000; i++) {

            targetString += "hello";

        }

        return targetString;

    }

    // JMH throughput benchmark: about 5,600 operations per second

    @Benchmark

    public String measureStringBufferApend() {

        StringBuffer buffer = new StringBuffer();

        for (int i = 0; i < 10000; i++) {

            buffer.append("hello");

        }

        return buffer.toString();

    }

    // JMH throughput benchmark: about 21,000 operations per second

    @Benchmark

    public String measureStringBuilderApend() {

        StringBuilder builder = new StringBuilder();

        for (int i = 0; i < 10000; i++) {

            builder.append("hello");

        }

        return builder.toString();

    }

对于字符串连接的操作，这个基准测试结果显示，使用 StringBuffer 的字符串连接操作，比使用 String 的操作快了近 200 倍；使用 StringBuilder 的字符串连接操作，比使用 String 的操作快了近 700 倍。

String 的字符串连接操作为什么慢呢？ 这是因为每一个字符串连接的操作（targetString += “hello”），都需要创建一个新的 String 对象，然后再销毁，再创建。这种模式对 CPU 和内存消耗都比较大。

StringBuilder 和 StringBuffer 为什么快呢？因为 StringBuilder 和 StringBuffer 的内部实现，预先分配了一定的内存。字符串操作时，只有预分配内存不足，才会扩展内存，这就大幅度减少了内存分配、拷贝和释放的频率。

StringBuilder 为什么比 StringBuffer 还要快呢？StringBuffer 的字符串操作是多线程安全的，而 StringBuilder 的操作就不是。如果我们看这两个方法的实现代码，除了线程安全的同步以外，几乎没有差别。

public final class StringBuffer

    extends AbstractStringBuilder

    implements java.io.Serializable, Comparable<StringBuffer>, CharSequence {

    // snipped

    @Override

    @HotSpotIntrinsicCandidate

    public synchronized StringBuffer append(String str) {

        toStringCache = null;

        super.append(str);

        return this;

    }

    //  snipped

}

public final class StringBuilder

    extends AbstractStringBuilder

    implements java.io.Serializable, Comparable<StringBuilder>, CharSequence {

    // snipped

    @Override

    @HotSpotIntrinsicCandidate

    public StringBuilder append(String str) {

        super.append(str);

        return this;

    }

    // snipped

}

JMH 的基准测试，并没有涉及到线程同步问题，难道使用 synchronized 关键字也会有性能损耗吗？

我们再来看看另外一个基准测试。这个基准测试，使用线程不安全的 StringBuilder 以及同步的字符串连接，部分模拟了线程安全的 StringBuffer.append() 方法的实现。为了方便你对比，我把没有使用同步的代码也拷贝在下面。

    // JMH throughput benchmark: about 21,000 operations per second

    @Benchmark

    public String measureStringBuilderApend() {

        StringBuilder builder = new StringBuilder();

        for (int i = 0; i < 10000; i++) {

            builder.append("hello");

        }

        return builder.toString();

    }

    // JMH throughput benchmark: about 16,000 operations per second

    @Benchmark

    public String measureStringBuilderSynchronizedApend() {

        StringBuilder builder = new StringBuilder();

        for (int i = 0; i < 10000; i++) {

            synchronized (this) {

                builder.append("hello");

            }

        }

        return builder.toString();

    }

这个基准测试结果显示，虽然基准测试并没有使用多个线程，但是使用了线程同步的代码比不使用线程同步的代码慢。线程同步，就是 StringBuffer 比 StringBuilder 慢的原因之一。

通过上面的基准测试，我们可以得出这样的结论：

频繁的对象创建、销毁，有损代码的效率；

减少内存分配、拷贝、释放的频率，可以提高代码的效率；

即使是单线程环境，使用线程同步依然有损代码的效率。

从上面的基准测试结果，是不是可以得出结论，我们应该使用 StringBuilder 来进行字符串操作呢？我们再来看几个基准测试的例子。

下面的例子，测试的是常量字符串的连接操作。从测试结果，我们可以看出，使用 String 的连接操作，要比使用 StringBuilder 的字符串连接快 5 万倍，这是一个让人惊讶的性能差异。

    // JMH throughput benchmark: about 1,440,000,000 operations per second

    @Benchmark

    public void measureSimpleStringApend() {

        for (int i = 0; i < 10000; i++) {

            String targetString = "Hello, " + "world!";

        }

    }

    // JMH throughput benchmark: about 26,000 operations per second

    @Benchmark

    public void measureSimpleStringBuilderApend() {

        for (int i = 0; i < 10000; i++) {

            StringBuilder builder = new StringBuilder();

            builder.append("hello, ");

            builder.append("world!");

        }

    }

这个巨大的差异，主要来自于 Java 编译器和 JVM 对字符串处理的优化。" Hello, " + " world! " 这样的表达式，并没有真正执行字符串连接。编译器会把它处理成一个连接好的常量字符串"Hello, world!"。这样，也就不存在反复的对象创建和销毁了，常量字符串的连接显示了超高的效率。

如果字符串的连接里，出现了变量，编译器和 JVM 就没有办法进行优化了。这时候，StringBuilder 的效率优势才能体现出来。下面的两个基准测试结果，就显示了变量对于字符长连接操作效率的影响。

    // JMH throughput benchmark: about 9,000 operations per second

    @Benchmark

    public void measureVariableStringApend() {

        for (int i = 0; i < 10000; i++) {

            String targetString = "Hello, " + getAppendix();

        }

    }

    // JMH throughput benchmark: about 26,000 operations per second

    @Benchmark

    public void measureVariableStringBuilderApend() {

        for (int i = 0; i < 10000; i++) {

            StringBuilder builder = new StringBuilder();

            builder.append("hello, ");

            builder.append(getAppendix());

        }

    }

    private String getAppendix() {

       return "World!";

    }

通过上面的基准测试，我们可以总结出下面的几条最佳实践：

Java 的编译器会优化常量字符串的连接，我们可以放心地把长的字符串换成多行；

带有变量的字符串连接，StringBuilder 效率更高。如果效率敏感的代码，建议使用 StringBuilder。String 的连接操作可读性更高，效率不敏感的代码可以使用，比如异常信息、调试日志、使用不频繁的代码；

如果涉及大量的字符串操作，使用 StringBuilder 效率更高；

除非有线程安全的需求，不推荐使用线程安全的 StringBuffer。

内存的泄露

内存泄漏是 C 语言的一个大问题。为了更好地管理内存，Java 提供了自动的内存管理和垃圾回收机制。但是，Java 依然会泄露内存。这种内存泄漏的主要表现是，如果一个对象不再有用处，而且它的引用还没有清零，垃圾回收器就意识不到这个对象需要及时回收，这时候就引发了内存泄露。

生命周期长的集合，是 Java 容易发生内存泄漏的地方。比如，可以扩张的静态的集合，或者存活时间长的缓存等。如果不能及时清理掉集合里没有用处的对象，就会造成内存的持续增加，引发内存泄漏问题。

比如下面这两个例子，就容易发生内存泄露。

静态的集合：

static final List<Object>

         staticCachedObjects = new LinkedList<>();

// snipped

staticCachedObjects.add(...);

长寿的缓存：

final List<Object>

        longLastingCache = new LinkedList<>();

// snipped

longLastingCache.add(...);

解决这个问题的办法通常是使用 SoftReference 和 WeakReference 来存储对象的引用，或者主动地定期清理。

静态的集合：

static final List<WeakReference<Object>>

        staticCachedObjects = new LinkedList<>();

// snipped

staticCachedObjects.add(...);

长寿的缓存：

final List<WeakReference<Object>>

        longLastingCache = new LinkedList<>();

// snipped

longLastingCache.add(...);

需要注意的是，缓存的处理是一个复杂的问题，使用 SoftReference 和 WeakReference 未必能够满足你的业务需求。更有效的缓存解决方案，依赖于具体的使用场景。

未关闭的资源

有很多系统资源，需要明确地关闭，要不然，占用的系统资源就不能有效地释放。比如说，数据库连接、套接字连接和 I/O 操作等。原则上，所有实现了 Closable 接口的对象，都应该调用 close() 操作；所有需要明确关闭的类，都应该实现 Closable 接口。

需要注意的是，close() 操作，一定要使用 try-finally 或者 try-with-resource 语句。要不然，关闭资源的代码可能很复杂。

如果一个类需要关闭，但是又没有实现 Closable 接口，就比较麻烦，比如 URLConnection. URLConnection.connect() 能够建立连接，该连接需要关闭，但是 URLConnection 没有实现 Closable 接口，关闭的办法只能是关闭对应的 I/O 接口，可是关闭 I/O 输入和输出接口中的一个，还不能保证整个连接会完全关闭。谨慎的代码，需要把 I/O 输入和输出都关闭掉，哪怕不需要输入或者输出。但是这样一来，我们的编码负担就会加重。所以最好的方法就是实现 Closable 接口。

双向关闭 I/O：

URL url = new URL("http://www.google.com/");

URLConnection conn = url.openConnection();

conn.connect();

try (InputStream is = conn.getInputStream()) {

    // sinnped

}

try (OutputStream os = conn.getOutputStream()) {

    // sinnped

}

单向关闭 I/O：

URL url = new URL("http://www.google.com/");

URLConnection conn = url.openConnection();

conn.connect();

try (InputStream is = conn.getInputStream()) {

    // sinnped

}

// The output strean is not close, the connection may be still alive.

遗漏的 hashCode

在使用 Hashtbale、HashMap、HashSet 这样的依赖哈希（hash）值的集合时，有时候我们会忘记要检查产生哈希值的对象，一定要实现 hashCode() 和 equals() 这两个方法。缺省的 hashCode() 实现，返回值是每一个对象都不同的数值。即使是相等的对象，不同的哈希值，使用基于哈希值的集合时，也会被看作不同的对象。这样的行为，可能不符合我们的预期。而且，使用没有实现 hashCode() 和 equals() 这两个方法的对象，可能会造成集合的尺寸持续增加，无端地占用内存，甚至会造成内存的泄漏。

所以，我们使用基于 hash 的集合时，一定要确保集合里的对象，都正确地实现了 hashCode() 和 equals() 这两个方法。

撞车的哈希值

实现 hashCode() 这个方法的，并没有要求不相等对象的返回值也必须是不相等的。但是如果返回的哈希值不同，对集合的性能就会有比较大的影响。

下面的两个基准测试结果显示，如果 10,000 个对象，只有 10 个不同的哈希值，它的集合运算的性能是令人担忧的。和使用了不用哈希值的实现相比，性能有几百倍的差异。

这种性能差异，主要是由基于哈希值的集合的实现方式决定的。哈希值如果相同，就要调用其他的方法来识别一个对象。哈希值如果不同，哈希值本身就可以确定一个对象的索引。如果哈希值撞车比例大，这种检索和计算的差距就会很大。

    // JMH throughput benchmark: about 5,000 operations per second

    @Benchmark

    public void measureHashMap() throws IOException {

        Map<HashedKey, String> map = new HashMap<>();

        for (int i = 0; i < 10000; i++) {

            map.put(new HashedKey(i), "value");

        }

    }

    private static class HashedKey {

        final int key;

        HashedKey(int key) {

            this.key = key;

        }

        @Override

        public boolean equals(Object obj) {

            if (obj == this) {

                return true;

            }

            if (obj instanceof HashedKey) {

                return key == ((HashedKey)obj).key;

            }

            return false;

        }

        @Override

        public int hashCode() {

            return key;

        }

    }

    // JMH throughput benchmark: about 9.5 operations per second

    @Benchmark

    public void measureCollidedHashMap() throws IOException {

        Map<CollidedKey, String> map = new HashMap<>();

        for (int i = 0; i < 10000; i++) {

            map.put(new CollidedKey(i), "value");

        }

    }

    private static class CollidedKey {

        final int key;

        CollidedKey(int key) {

            this.key = key;

        }

        @Override

        public boolean equals(Object obj) {

            if (obj == this) {

                return true;

            }

            if (obj instanceof CollidedKey) {

                return key == ((CollidedKey)obj).key;

            }

            return false;

        }

        @Override

        public int hashCode() {

            return key % 10;

        }

    }

小结

今天，我们主要讨论了一些容易被忽略的性能陷阱。比如，字符串怎么操作才是高效的；Java 常见的内存泄漏；资源关闭的正确方法以及集合的相关性能问题。

我们虽然使用了 Java 作为示例，但是像集合和字符串操作这样的性能问题，并不局限于特定的编程语言，你也可以看看你熟悉的编程语言有没有类似的问题。

一起来动手

这一次的练手题，我们来练习使用 JMH 工具，分析更多的性能问题。在“撞车的哈希值”这一小节，我们测试了 HashMap 的 put 方法，你能不能测试下其他方法以及其他基于哈希值的集合（HashSet，Hashtable）？我们测试的是 10,000 个对象，只有 10 个哈希值。如果 10,000 个对象，有 5,000 个哈希值，性能影响有多大？

下面的这段代码，你能够找到它的性能问题吗？

package com.example;

import java.util.Arrays;

import java.util.Random;

public class UserId {

    private static final Random random = new Random();

    private final byte[] userId = new byte[32];

    public UserId() {

        random.nextBytes(userId);

    }

    @Override

    public boolean equals(Object obj) {

        if (obj == this) {

            return true;

        }

        if (obj instanceof UserId) {

            return Arrays.equals(this.userId, ((UserId)obj).userId);

        }

        return false;

    }

    @Override

    public int hashCode() {

        int retVal = 0;

        for (int i = 0; i < userId.length; i++) {

            retVal += userId[i];

        }

        return retVal;

    }

}

我们前面讨论了下面这段代码的性能问题，你能够使用 JMH 测试一个你的改进方案带来的效率提升吗？

import java.util.HashMap;

import java.util.Map;

class Solution {

    /**

     * Given an array of integers, return indices of the two numbers

     * such that they add up to a specific target.

     */

    public int[] twoSum(int[] nums, int target) {

        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {

            int complement = target - nums[i];

            if (map.containsKey(complement)) {

                return new int[] { map.get(complement), i };

            }

            map.put(nums[i], i);

        }

        throw new IllegalArgumentException("No two sum solution");

    }

}

另外，你也可以检查一下你手头的代码，看看有没有踩到类似的坑。如果遇到类似的陷阱，看一看能不能改进。

容易被忽略的性能陷阱，有很多种。这些大大小小的经验，需要我们日复一日的积累。如果你有这方面的经验，或者看到这方面的技术，请你分享在留言区，我们一起来学习、积累这些经验。

也欢迎点击“请朋友读”，把这篇文章分享给你的朋友或者同事，一起交流一下。

27 | 怎么编写可持续发展的代码？

成功的大公司，也是从小公司起步的。刚开始的时候，软件可能比较简单，用户也比较少，一台廉价的服务器，或者一个简单的虚拟机，甚至几个静态的页面就绰绰有余。

很快，辛苦的努力得到回报，产品传播速度远超预期，用户很喜欢公司的产品或者服务，数量大幅增加，需求越来越强劲。这时候也是公司最忙碌的时候，每个人眼里只有两个字：“增长”。用户规模增长随之带来的是软件规模增长，运维复杂度增长。这时候，廉价的服务器满足不了需求了，就需要更多的服务器，甚至是各种用途不一样的服务器，还需要使用更多的带宽、更多的内存、更多的 CPU，甚至更多的硬盘。

跟得上增长的公司，用户会越来越喜欢，就会脱颖而出，每一份辛苦都得到了优厚的回报；跟不上增长的，用户会越来越抱怨，公司会被迅速模仿，然后用户被抢走，公司被迅速甩开，每一份辛苦都成了一声叹息。

增长对软件的要求，就是要有处理越来越多工作和越来越大规模的能力或者潜力。这种能力，通常称之为可伸缩性（Scalability）。

不过，要提醒的是，也有人使用“可扩展性”这个词表示规模的扩张能力。可扩展性这个词汇很多时候也用于表示功能的扩展（Extensibility）。这就容易混淆规模扩展和功能扩展这两个完全不一样的概念。如果有人使用了可扩展性这个概念，要弄清楚指的是规模还是功能。

为了方便理解，减少混淆，我们使用更通俗一点的词汇来表达这两个概念。这两个词汇就是规模扩张能力（Scalability）和功能扩展能力（Extensibility）。

规模扩张能力，是依赖于具体的代码的。不是所有的代码都能适应规模的扩张。这一次，我们就来讨论代码的规模扩张能力，以及一些常见的陷阱。

两种规模扩张方式

规模扩张主要有两种方式。一种方式是规模垂直扩张（scale in/out），另一种是规模水平扩张（scale up/down）。

规模垂直扩张，指的是提高同一个处理单元处理更多负载的能力。比如，硬件上，增加服务器的硬盘、内存和 CPU；软件上，优化算法、程序和硬件的使用等。

规模垂直扩张是传统的提高负载的方式，方式方法都比较直观，效果也立竿见影。但是，规模垂直扩张成本很高，而且是非线性的。比如说，4 倍的 CPU 可能只提高 2 倍的负载，而 8 倍的 CPU 可能只提高 2.5 倍的负载。

另外，规模垂直扩张是有上限的，一台服务器的处理能力不是可以无限扩展的。还有，硬件的规模扩张，可能要更换机器，停止软件运行。这种规模扩张方式，不太适用于可用性要求高的产品。比如我们常用的微信，出现 5 分钟的停机都是天大的事故。

规模水平扩张，指的是通过增加更多的处理单元，来处理更多的负载。我们常见的例子，就是增加服务器。分布式系统、负载均衡、集群系统这些技术，提供的就是规模水平扩张的能力。

优秀的规模水平扩张技术，可以使用很多廉价的机器，提供大规模的计算能力。一般情况下，规模水平扩张的成本要低于规模垂直扩张。而且，如果其中一个节点出了问题，只要其他节点还在正常工作，整个系统也可以照常运转。如果想要添加一个新节点，系统也不需要停顿。规模水平扩张技术的这些特点，非常适用于高可用性系统。

和规模垂直扩张相比，规模水平扩张的方式方法并不直观。支持规模水平扩张的代码要能够协调地运行在不同的机器上，也就是说要支持分布式计算。很多代码，不是天生就支持分布式计算的，而且修改起来也不容易。

我们常说的优化代码，一般指的是提高每一个处理单元的使用效率，也就是规模垂直扩张能力。其实，我们还要考虑代码是不是能够分布式运行，也就是规模水平扩张能力。

麻烦的状态数据

影响代码水平规模扩张的最重要的一个因素，就是用户的状态数据。比如，用户的登录状态，用户购物车里的商品信息，HTTP 连接里缓存的会话数据等。

如果用户访问一个服务节点时，在节点留下了状态。这个状态就要在多个节点间同步。否则，如果用户下一次访问被分配到不同的服务节点，这个状态就会消失不见。比方说吧，上午，我们在一个网站购物，把待选的商品放到购物车里。这个选择商品的过程，可能是由位于北京南城的数据中心的一台服务器处理的。下午，我们准备结账，重新访问这个购物网站。这时候，服务器可能是由位于贵州的数据中心提供的。如果上午访问和下午访问的服务器之间没有同步购物车数据，下午访问时，购物车里就没有我们想要的信息了。

购物车的状态同步，可以通过分布式数据库来解决。分布式数据库自动处理多个节点之间的数据同步。

现在的软件服务，大都是基于 HTTP 协议提供的 Web 服务。Web 服务本身就是一个无状态的协议。即使可以保持 HTTP 的连接，一般的服务框架也会考虑在连接不能保持情况下的会话管理，也就是保存用户状态。HTTP 协议层面的状态管理，也需要支持分布式计算。搭建支持规模水平扩张的 Web 服务时，要做好 Web 服务框架的选型。

如果我们的代码里，保存了状态数据，可能会影响规模水平扩张的能力。比如说下面的这个例子中的 sessionCache 这个静态变量，如果用来保存用户的会话，并且使用 SessionId 匹配用户行为，规模水平扩张时就会遇到麻烦。因为，这个变量内容的更改，只存在于运行它的节点里，不能在一个分布式系统的每个节点之间同步。

    private static final HashMap<SessionId, byte[]> sessionCache = new HashMap();

对于规模水平扩张的需求，状态数据是一个很麻烦的存在。甚至，一些基础的，需要保存状态数据的网络协议，在早期的版本中也没有考虑规模水平扩张的问题。这就给规模水平扩张带来了一定的困难。

所以，采用规模水平扩张时，一定要小心代码的状态数据能不能同步。另外，由于软件依赖的基础设施问题，还要测试软件的运行平台是否能够很好地支持规模水平扩张。

无状态数据

如果一个服务是无状态的，规模水平扩张就会非常顺利。比如说，静态的网页，静态的图片，静态的商品描述，静态的 JavaScript 和 CSS 文件等等。由于不需要在服务端保留状态，这些数据资源就不需要在不同的节点间实时同步。无状态的数据，可以降低规模水平扩张的技术复杂性，在技术上有了更多的改进空间。

比如说，现代的浏览器，都可以缓存静态数据，比如说静态的 JavaScript 和 CSS 文件。如果用户访问的两个不同网站，使用了相同的脚本文件。浏览器只需要下载一次脚本文件，就可以在两个网站使用。这样，缓存的脚本文件就可以加速网页的加载，减轻服务器的压力。

分离无状态数据

由于无状态数据有这么多的优点，把无状态数据分离出来，单独提供服务就成了一个常用的解决方案。独立的无状态数据服务，既没有规模水平扩张的羁绊，还能充分利用客户端的缓存。另外，无状态数据和状态数据的分离，可以让状态数据的处理集中资源，也能提高状态数据的处理能力。

比如说，一个网站如果使用了共享的 jquery.js 脚本，下载这个脚本，就不再占用这个网站的资源了。

所以，如果你要设计一个具有规模扩张能力的软件架构，分离无状态数据和状态数据，既可以提高规模水平能力，也可以提高规模垂直扩张能力。

使用用户资源

对静态数据进行缓存，是现代浏览器提高服务性能的一个重要办法。除此之外，浏览器还可以缓存动态数据，比如 HTTP 的 cookie 以及 HTTPS 的安全连接的参数。

鉴于无状态数据的诸多优点，一些协议设计开始考虑无状态的服务场景。比如，TLS 1.3 就加入了对无状态服务的支持。

无状态服务，并不一定都没有服务状态。一个典型的做法是，服务端通过一定的数据保护机制，把服务状态保护起来，发送到客户端。然后，客户端缓存封印的服务状态。下次连接时，客户端把封印的服务状态原封不动地送回到服务端。然后，服务端解封客户端发送的封印服务状态，就获得了需要处理的状态数据。这样，既有了无状态服务的便利，解除了规模水平扩张的限制，又解决了服务需要状态的客观需求。

遗憾的是，这种设计能够提供的服务状态数据尺寸比较有限，应用场景也比较苛刻，而且数据保护机制一般也比较复杂。所以，我们一般要在基础架构层面解决掉核心的问题（数据保护机制、服务状态封存机制、缓存机制等）。然后，在应用层面仅仅定制状态的内容，比如 HTTP 的 cookie 的格式和数据，就是可以定制的内容。而 HTTP 的 cookie 交换的机制，就由 HTTP 协议负责解决。

小结

如果把我们上面讨论的放到一起，就可以得到具有规模扩张能力的软件的一些最佳实践：

把无状态数据分离出来，单独提供无状态服务；

把最基本的服务状态封装起来，利用客户端的缓存，实现无状态服务；

小心使用服务状态，编码时要考虑服务状态的规模水平扩张能力。

基于上述的原则，市场上有很多优秀的解决方案和成熟技术。欢迎你在留言区分享、讨论这些解决方案和技术。

一起来动手

这一次的练手题，我们要拆解一下 Web 页面。找一个你常用的 Web 服务，比如说 InfoQ 或者极客时间。使用浏览器的插件，阅读这个 HTML 页面，试着分析下，这个页面里哪些可能是动态数据，哪些可能是静态数据？这个页面是怎么处理这些数据的？使用我们今天讨论的基本原则，这个页面还有没有优化的空间？如果你侧重于服务端的编码，你想想服务器端该做什么样的调整？

欢迎你在留言区留言，分享你的看法。也欢迎点击“请朋友读”，把这篇文章分享给你的朋友或者同事，一起交流一下。

28 | 怎么尽量“不写”代码？

最有效率的编码就是少编写代码，甚至不编写代码。前面，我们讨论过避免需求膨胀和设计过度，就是减少编码的办法之一。这一次，我们讨论代码复用的问题。商业的规模依赖于可复制性，代码的质量依赖于可复用性。

比如，Java 提供了很多的类库和工具，就是为了让 Java 程序员不再编写类似的代码，直接拿来使用就可以了。

不要重新发明轮子

“不要重新发明轮子”，这是一个流传甚广的关于软件复用的话。如果已经有了一个轮子，可以拿来复用，就不用再重新发明一个新轮子了。复用，是这句话的精髓部分。

如果没有现成的轮子，我们需要造一个新的。如果造的轮子可以复用，那就再好不过了。造轮子的过程，就是我们设计和实现复用接口的过程。

我刚参加工作的时候，从事的是银行综合业务系统的研发工作。银行的业务，牵涉到大量的报表。每一个报表的生成和处理，都是一个费力的编码环节。需要大量的代码，反复调试，才能生成一张漂亮的报表。那时候，市面上也没有什么可以使用的解决方案。我有一个同事负责这方面的工作，刚开始的辛苦程度可想而知。

过了几年，我们再聊起报表业务的时候，发现他已经在报表处理方面建立了巨大的优势。这个优势，就是报表处理代码的复用。他把报表的生成和处理，提炼成了一个使用非常简单的产品。用户只要使用图形界面做些简单的配置，就能生成漂亮的报表。编写大量代码、反复调试的时代，已经一去不复返了。传统的方式需要几个月的工作量，使用这个工具几天时间就搞定了。而且，客户还可以自己定义生成什么样的报表。生成花样报表的需求依然存在，但是再也不需要大量的重复劳动了。这个产品的优势，帮助他赢得了很多重要的客户。

什么样的代码可以复用呢？一般来说，当我们使用类似的代码或者类似的功能超过两次时，就应该考虑这样的代码是不是可以复用了。比如，当我们拷贝粘贴一段代码时，也许会做一点微小的修改，然后用到新的代码里。这时候，我们就要考虑，这段拷贝的代码是不是可以抽象成一个方法？有了抽象出来的方法，我们就不需要把这段代码拷贝到别的地方了。如果这段代码有错误，我们也只需要修改这个方法的实现就可以了。

推动轮子的改进

轮子发明出来了，并不意味着这个轮子就永远没有问题了。它是需要持续改进的，比如，修改错误，修复安全问题，提高计算性能等等。

“不要重新发明轮子”这句话的另外一层意思，就是改进现有的轮子。如果发现轮子有问题，不要首先试图去重新发明一个相同的轮子，而是去改进它。

每一个可以复用的代码，特别是那些经过时间检验的接口，都踩过了很多坑，经过了多年的优化。如果我们试着重新编写一个相同的接口，一般意味着这些坑我们要重新考虑一遍，还不一定能够做得更好。

比如说吧，我们前面提到了 Java 核心类库里 String 类的设计缺陷。为了避免这样的缺陷，我们当然可以发明一个新的 MyString 类。但是，这意味着我们要维护它以保持它长久的生命力。Java 的 String 类，有 OpenJDK 社区的强大支撑，有几十亿台设备使用，有专业的人员维护、更新和改进。而我们自己发明的 MyString 类，就很难有这样的资源和力量去维护它。

当然，我们也不能坐等轮子的改进。 如果一个可以复用的代码出了问题，我们要第一时间叫喊起来。这对代码的维护者而言，是一个发现问题、改进代码的机会。一般来说，代码维护者，都喜欢这样的声音，并且能够及时地反馈。我们可以通过发邮件，提交 bug 等我们知道的任何渠道，让代码的维护者知晓问题的存在。这样，我们就加入了改进的过程，间接影响了代码的质量。

使用现有的轮子固然方便，但是如果它满足不了你的需求，或者你不能使用，也不要被“不要重新发明轮子”这句话绊住了脚。需要新轮子的时候，就去发明新轮子。

如果你去观察市场，每一种好东西，都可能有好几个品牌在竞争。手机不仅仅只有一个品牌，豆浆机也不仅仅只有一个型号，云服务也不仅仅由一家提供，互联网支付也有多种选择。如果仔细看，类似的产品也有很多不同的地方。不同的地方，就是不同的产品有意或者无意做的市场区隔。

不要重复多个轮子

市场上存在多个轮子是合理的。但是在一个软件产品中，一个单一功能，只应该有一个轮子。如果有多个相同的轮子，不仅难以维护，而且难以使用，会造成很多编码的困扰。

比如说，在 JDK 11 中，我们引入了一个通过标准名称命名已知参数的类。

package java.security.spec;

/**

 * This class is used to specify any algorithm parameters that are determined

 * by a standard name.

 * <snipped>

 */

public class NamedParameterSpec implements AlgorithmParameterSpec {

    public NamedParameterSpec(String standardName) {

        // snipped

    }

    public String getName() {

        // snipped

    }

}

这个类单独看，并没有什么不妥当的地方。但是，如果放在更大范围里来看，这个新添加的类就引起了不小的麻烦。这是因为还存在另外一个相似的扩展类。

而且，由于这个扩展类和它继承的类，功能几乎完全重合，带来的困扰就是，本来我们只需要一个轮子就能解决的问题，现在不得不考虑两个轮子的问题。而且，由于 ECGenParameterSpec 的存在，我们还可能忘记了要考虑使用更基础的 NamedParameterSpec 类。

问题代码：

@Override

public void initialize(AlgorithmParameterSpec params)

            throws InvalidAlgorithmParameterException {

    // snipped

    if (params instanceof ECGenParameterSpec) {

        String name = ((ECGenParameterSpec)params).getName();

    } else {

        throw new InvalidAlgorithmParameterException(

                "ECParameterSpec or ECGenParameterSpec required for EC");

    }

    // snipped

}

正确代码：

@Override

public void initialize(AlgorithmParameterSpec params)

            throws InvalidAlgorithmParameterException {

    // snipped

    if (params instanceof NamedParameterSpec) {

        String name = ((NamedParameterSpec)params).getName();

    } else {

        throw new InvalidAlgorithmParameterException(

                "ECParameterSpec or ECGenParameterSpec required for EC");

    }

    // snipped

}

上面的问题，是 JDK 11 引入的一个编码困扰。这个困扰，导致了很多使用的问题。由于是公开接口，它的影响，要经过好多年才能慢慢消除。也许很快，在 JDK 的某一个版本中，这个扩展的 ECGenParameterSpec 类就会被废弃掉。

该放手时就放手

你有没有这样的体验，一个看起来很微不足道的修改，或者没有任何问题的修改，会带来一连串的连锁反应，导致意想不到的问题出现？

前不久，OpenJDK 调整了两个方法的调用顺序。 大致的修改就像下面的例子。

修改前：

Signature getSignature(PrivateKey privateKey,

    AlgorithmParameterSpec signAlgParameter) throws NoSuchAlgorithmException,

    InvalidAlgorithmParameterException, InvalidKeyException {

    Signature signer = Signature.getInstance("RSASSA-PSS");

    if (signAlgParameter != null) {

        signer.setParameter(signAlgParameter);

    }

    signer.initSign(privateKey);

    return signer;

}

修改后：

Signature getSignature(PrivateKey privateKey,

    AlgorithmParameterSpec signAlgParameter) throws NoSuchAlgorithmException,

    InvalidAlgorithmParameterException, InvalidKeyException {

    Signature signer = Signature.getInstance("RSASSA-PSS");

    signer.initSign(privateKey);

    if (signAlgParameter != null) {

        signer.setParameter(signAlgParameter);

    }

    return signer;

}

这个修改仅仅调换了一下两个方法的调用顺序。根据这两个方法的接口规范，调用顺序的修改不应该出现任何问题。然而，让人意向不到的是，这个接口的实现者，大都依赖于严格的调用顺序。修改前的调用顺序，已经使用了十多年了，大家都习以为常，认为严格的调用顺序依赖并没有问题。一旦改变了这个调用顺序，很多应用程序就不能正常工作了，就会出现严重的兼容性问题。

我们每个人都会写很多烂代码，过去写过，未来可能还会再写。这些烂代码，如果运行得很好，没有出现明显的问题，我们就放手吧。

但不是说烂代码我们就永远不管不问了。那么，什么时候修改烂代码呢？代码投入使用之前，以及代码出问题的时候，就是我们修改烂代码的时候。

那么代码的修改都有哪些需要注意的地方呢？

代码规范方面的修改，可以大胆些。比如命名规范、代码整理，这些都动不了代码的逻辑，是安全的修改。

代码结构方面的修改，则要谨慎些，不要伤及代码的逻辑。比如把嵌套太多的循环拆分成多个方法，把几百行的代码，拆分成不同的方法，把相似的代码抽象成复用的方法，这些也是相对安全的修改。

代码逻辑方面的修改，要特别小心，除了有明显的问题，我们都尽量避免修改代码的逻辑。即使像上面例子中那样的微小的调用顺序的改变，都可能有意想不到的问题。

小结

今天，我们聊了代码复用的一些基本概念。关键的有三点：

要提高代码的复用比例，减少编码的绝对数量；

要复用外部的优质接口，并且推动它们的改进；

烂代码该放手时就放手，以免引起不必要的兼容问题。

一起来动手

今天的练手题，我们来分析下 OpenJDK 的一个接口设计问题。

不可更改的集合，是 OpenJDK 的核心类库提供的一个重要功能。这个功能，有助于我们设计实现“一成不变”的接口，降低编码的复杂度。

从 JDK 1.2 开始，这个功能是通过 Collections 类的方法实现的。比如 Collections.unmodifiableList() 方法。

public static <T> List<T> unmodifiableList​(List<? extends T> list)

Returns an unmodifiable view of the specified list. Query operations on the returned list "read through" to the specified list, and attempts to modify the returned list, whether direct or via its iterator, result in an UnsupportedOperationException.

The returned list will be serializable if the specified list is serializable. Similarly, the returned list will implement RandomAccess if the specified list does.

Type Parameters:

    T - the class of the objects in the list

Parameters:

    list - the list for which an unmodifiable view is to be returned.

Returns:

    an unmodifiable view of the specified list.

在 JDK 10 里，又添加了新的生成不可更改的集合的方法。比如 List.copyOf() 方法。

static <E> List<E> copyOf​(Collection<? extends E> coll)

Returns an unmodifiable List containing the elements of the given Collection, in its iteration order. The given Collection must not be null, and it must not contain any null elements. If the given Collection is subsequently modified, the returned List will not reflect such modifications.

Implementation Note:

    If the given Collection is an unmodifiable List, calling copyOf will generally not create a copy.

Type Parameters:

    E - the List's element type

Parameters:

    coll - a Collection from which elements are drawn, must be non-null

Returns:

    a List containing the elements of the given Collection

Throws:

    NullPointerException - if coll is null, or if it contains any nulls

Since:

    10

比较两个接口，你能够理解新接口的改进吗？为什么新加了一个接口，而不是改进原来的接口？为什么使用了一个新的类（List），而不是在原来的类（Collections）里加一个新方法？

欢迎你在留言区讨论上面的问题，我们一起来了解很多接口设计背后的妥协，以及接口演进的办法。也欢迎点击“请朋友读”，把这篇文章分享给你的朋友或者同事，一起交流一下。

29 | 编写经济代码的检查清单

通过前面十几讲的学习，我们已经把代码“经济”篇的内容学习完了。今天，我们一起把前面讨论到的观点总结一下，并探索一下编写经济代码时的最佳实践检查清单。

为什么需要经济的代码？

我在经济篇这一模块开始的时候讲过这个问题，这里再来简单回忆一遍。

1. 提升用户体验

一致性的性能体验，是软件产品赢得竞争的关键指标。复杂的，反应迟钝的软件，很难赢得用户的尊敬。

2. 降低研发成本

通过降低软件的复杂度，提高软件的复用，提前考虑性能问题，可以降低软件研发成本，缩短软件开发周期。

3. 降低运营成本

经济的代码可以降低软件的复杂度，提高计算资源的使用效率，降低运营成本。

4. 防范可用性攻击

复杂的代码和性能低下的代码，更容易成为黑客攻击的目标。如果一个服务器，需要耗费很多资源才能处理一个请求，那么数量很少的模拟请求攻击，就可以导致服务器瘫痪。

怎么编写经济的代码？

既然我们都知道编写经济代码的重要性，那么如何让自己的代码经济又高效呢？

在前面的文章中，我给你从避免过度设计、选择简单直观、超越线程同步、减少内存使用、避免性能陷阱、规模扩张能力等角度探讨了一些方法，下面我提炼了几个点，我们再来重新温习一遍。

1. 避免过度设计

我们从需求和设计两个角度探讨了代码的经济问题。

避免需求膨胀的方式主要有两个，第一个是识别核心需求，我们要从用户的角度出发，知道什么是核心需求，什么是衍生需求，什么是无效需求。就像建火车站一样，能够满足乘客出行需求的就是好的设计方案，其他方面再细心认真，起到的也只是锦上添花的效果。那么有一些功能现在好像用不上，但又必须做，该怎么办呢？这就用到了第二个方法：迭代演进，有所主次。

避免过度设计和避免需求膨胀一样，我们需要时刻问自己，什么是现在就必须做的？什么是必须做的？

搞清楚这两个问题，有助于我们始终关注核心需求和核心问题，为代码的质量和编码的效率打好基础。

避免需求膨胀和过度设计，是编写经济代码最需要注意的根基性问题。

2. 选择简单直观

我们用了两篇文章，讨论了让代码简单直观的原则和实践。

设计一个简单直观的接口，首先，我们要从问题开始。把问题逐步拆解成一个个已经完全穷尽的小问题，这就是我讲到的“相互独立，完全穷尽”原则。在拆解的过程中，软件的接口与接口之间的关系会自然而然地产生。

此外我们还要注意，一个接口只应该做一件事情，如果这个情况太理想化，就要想办法减少接口的依赖关系。

一定记住这个经过实践检验的理念：选择最简单，最直观的解决方案。

3. 超越线程同步

现实中，线程同步需要排队，有损效率。我们用了两篇文章，主要讲了该怎么超越线程的同步。

只要满足这三个条件中的一个，我们就不需要线程同步了：使用单线程；不关心共享资源的变化；没有改变共享资源的行为。

我们要重新认识 Java 的“final”这个限定词。使用了限定词“final”的类变量，只能被赋值一次，而且只能在实例化之前被赋值。这样的变量，就是不可变的量。如果一个类的所有的变量，都是不可变的，那么这个类也是不可变的。不可变的量是无法改变的资源，不需要线程同步。

如果线程同步不可避免，就要想办法减少线程同步时间。

另外，我们还讨论了如何使用同步的代码，调动异步的事件。异步编程，可以大幅度降低线程同步的使用，更有效地使用计算机资源。

4. 减少内存使用

内存管理对任何一门编程语言来讲都是一个难题。我们用了两篇文章，讨论了提高内存使用效率的一些方法。

减少内存的使用主要有两个方法，第一个方法是减少实例的数量，第二个办法是减小实例的尺寸。

如何减少实例的数量呢？我们可以使用数据静态化的处理方式（比如枚举类型）、用单实例模式、延迟分配技术等。

在减小实例尺寸这一模块，我们要尽量减少独占的空间，尽量使用共享的实例。不可变（immutable）的资源和禁止修改（unmodifiable）的资源，是两类理想的共享资源。

5. 规避性能陷阱

我们要学会规避一些常见的性能陷阱，比如字符串的操作、内存泄露、未正确关闭的资源和遗漏的 hashCode 等。

另外，我们还顺便使用了一个基准测试工具 JMH，并通过它分析了一些性能陷阱。我们要有意识地使用一些性能测试工具，通过测试数据来认识、积累性能问题的最佳实践。

6. 规模扩张能力

经济的代码需要跟得上产品的规模扩张。我们要理解规模垂直扩张和规模水平扩张这两种方式，特别是支持规模水平扩张。

状态数据是影响规模水平扩张能力的最重要的因素。分离无状态数据、提供无状态服务，减少有状态服务的规模，是提升规模水平扩张能力的最佳实践。

经济代码的检查清单

了解了编写经济代码的方法论之后，我们再来看下检查清单。这个检查清单是经济篇这一模块的凝练，也是我看代码的时候，通常会使用的检查点。你也可以参考一下。

如果有检查点没有通过，那么你在阅读代码的时候，就要集中注意力，深入分析；在设计和编写代码的时候，要花时间衡量、妥协、改进；在评审代码的时候，要问清楚为什么这么做，能不能有所改进，并且给出合理的建议。

需求评审

需求是真实的客户需求吗？

要解决的问题真实存在吗？

需求具有普遍的意义吗？

这个需求到底有多重要？

需求能不能分解、简化？

需求的最小要求是什么？

这个需求能不能在下一个版本再实现？

设计评审

能使用现存的接口吗？

设计是不是简单、直观？

一个接口是不是只表示一件事情？

接口之间的依赖关系是不是明确？

接口的调用方式是不是方便、皮实？

接口的实现可以做到不可变吗？

接口是多线程安全的吗？

可以使用异步编程吗？

接口需不需要频繁地拷贝数据？

无状态数据和有状态数据需不需要分离？

有状态数据的处理是否支持规模水平扩张？

代码评审

有没有可以重用的代码？

新的代码是不是可以重用？

有没有使用不必要的实例？

原始数据类的使用是否恰当？

集合的操作是不是多线程安全？

集合是不是可以禁止修改？

实例的尺寸还有改进的空间吗？

需要使用延迟分配方案吗？

线程同步是不是必须的？

线程同步的阻塞时间可以更短吗？

多状态同步会不会引起死锁？

是不是可以避免频繁的对象创建、销毁？

是不是可以减少内存的分配、拷贝和释放频率？

静态的集合是否会造成内存泄漏？

长时间的缓存能不能及时清理？

系统的资源能不能安全地释放？

依赖哈希值的集合，储存的对象有没有实现 hashCode() 和 equals() 方法？

hashCode() 的实现，会不会产生撞车的哈希值？

代码的清理，有没有变更代码的逻辑？

小结

编写经济的代码，是我们在编程入门之后，需要积累的一项重要技能。正是因为要考虑性能、安全等因素，编写代码才成了一个具有挑战性的工作。

如果我们有以下这两个好习惯，那么编写经济的代码的能力就会越来越强大。

第一个习惯是，要尽早地考虑性能问题。如果你最早接触的是需求制定，就从需求开始考虑；如果你最早接触的是软件架构，就从架构层面开始考虑；如果你最早接触的是软件设计，就从软件设计开始考虑；如果你最早接触到的是代码，代码也有很多性能问题可以考虑。总之，要主动、尽早地考虑效率问题。

第二个习惯是，性能的实践经验需要日积月累。性能的实践经验和技术丰富繁杂，大到产品蓝图，小到每一行代码，中间还有软件的架构、选型、部署等诸多环节，都有很多的最佳实践可以积累。而且这些最佳实践，也会随着时间的推移发生变化，比如说会出现更好的技术方案，曾经的技术满足不了新需求等。所以，我们也要随时更新我们的储备，摒弃过时的经验。

希望你根据自己的实际情况，不断修改、完善、丰富上面的清单，让这份清单更契合你自己的工作领域。

一起来动手

不同的场景，检查清单也不一定相同。我上面的清单，就没有考虑数据库和 Web 服务架构。如果让你列一个你实际工作中需要的，编写经济代码的检查清单，会是什么样子的？ 你可以在我上面的清单上加减检查点，或者新做一个列表。欢迎在留言区公布你的检查清单，我们一起来讨论、学习。

另外，推荐一本书《重新定义公司——谷歌是如何运营的》。如果你没有时间，看看随书附带的小册子也行。这本书，谈的虽然是公司运营，但是我们可以也从中学习到如何设计优秀的产品，如何编写优秀的代码的一些基本思想。

推荐的另外一本书是《Effective Java》。建议找找最新的版本（现在是第三版）。这本书里，有很多非常实用的小经验，每一个小经验都讲得深入又透彻。是一本 Java 程序员必备的好书。

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

30丨“代码经济篇”答疑汇总

到这一篇文章，意味着专栏第二模块“经济的代码”已经更新完毕了。非常感谢同学们积极踊跃地留言，提出了很多独到的见解，我自己也学到了不少新东西。

今天，我来集中解答一下留言区里的一些疑问。有很多问题，我们已经在留言区里讨论了。这里，我们就挑选一些还没有解决掉的问题，深入讨论一下。

@秦凯

对性能和资源消耗有一定的意识，但是在具体的开发过程中或者应用运行过程中要对性能进行监控、评测、分析，就束手无策了。

答：我一直都认为，有了意识，其实就成功一大半了。有了意识，我们就会去寻找技术，寻找工具，寻找解决的办法。到了这个专栏的第三个部分（也是接下来要更新的“安全篇”），我们就会更强烈地感受到，“要有意识”是我们首先要获得的能力。大部分代码的问题，其实都是意识和见识带来的问题。

回到这个问题本身，性能的监控、评测和分析，我们通常要通过一定的工具来解决。

第一个常用的工具是 JMH，我们在第 26 篇里简单介绍了这个小工具的用法。JMH 可以用来测试一个接口、一段代码跑得有多快。特别是当我们面临两个选择，并且犹豫不决的时候，对比 JMH 的测试结果，就可以给我们提供一个大致准确的方向。

第二个常用的工具是性能回归测试。我们经常修改代码，修改后的代码性能有没有变坏？这是一个值得我们警惕的问题。我们可以通过自动化的性能回归测试，来检测修改有没有恶化代码的性能。就像普通的回归测试一样，性能回归测试也需要写测试代码。编写自动的回归测试代码就相当于我们制造了一个工具。从长远看，工具可以提升我们的工作效率。

第三个就是找一款成熟的性能调试工具，比如 NetBeans Profiler、JProfiler、Java Mission Control、Stackify Prefix 等。这些性能调试工具，能够综合地检测性能问题，比如内存、CPU、线程的使用状况，或者最耗费资源的方法等。

第四个办法就是用实时的性能监控工具，比如 New Relic APM，Stackify Retrace 等。这个工具可以用在运营环境上，预警性能障碍，检测性能瓶颈。

如果掌握了这四样工具，很多性能问题，我们都可以早发现、早解决。

@悲劇の輪廻

某些银行的客户端已经奔着 150M+ 去了……我怀疑他们的开发人员是不是也经过层层外包，根本不考虑客户终端的运行环境。

答：@悲劇の輪廻 提出了一个好问题。代码的尺寸，也是一个我们需要考量的重要指标。

在 JDK 9 中，Java 开始支持模块化（Java module）。Java 模块化背后的一个重要推动力量，就是 JDK 的尺寸。

在云服务，特别是微服务，和移动计算到来之前，我们认为硬盘不值钱，所以一个软件的尺寸大一点也没有关系。

但是对于云服务和微服务来说，使用了多少硬盘空间，也是一个重要的计费项目。这时候，软件的尺寸带来的开销可就是一个常规的日常费用了。

对于移动计算，比如我的手机，空间只有可怜的 16G。一旦存储报警，我几乎没有太多的选择余地，只好删除那些不太常用的、占用空间又大的 App。是不是 App 的开发者，也应该琢磨下怎么节省用户的手机空间呢？

@风清扬笑

话说“第一眼看到钱”这个需求貌似是很多人想要的，但是我觉得没有这么做的部分原因也是基于安全考虑，一些 APP 设计把余额放到二级菜单里，而且想看的话还得输入密码。

@IamNigel

对于银行 App 我最想看到钱，可以转账，可以管理我的银行卡信息，最近两年在用平安银行的手机 App，看余额得输入密码，这个应该是安全考虑，但其中的很多功能从来不会去点，特别是任意门，一不小心就点上去了。好的地方也有，像转帐以后会记录我最近转过的信息，也是比较方便。

答：@风清扬笑和 @IamNigel 都提到了密码登录的问题。这个问题，是一个传统而又典型的身份认证（Authentication）方式。

有人开玩笑， 我们受够了密码，但是离了密码又活不了 (We cannot live with password; we cannot live without password.)。 密码导致的问题太多了，我们实在没有办法爱上它。二十年前，就有人断言，密码必死无疑。无密码的解决方案也是一茬接一茬地出现。可实际情况是，密码自己越活越洒脱，越活越有样子。现代的新技术，比如指纹、瞳膜、面部识别，都有着比密码更严肃的的缺陷，替代不了传统的密码。

有没有可以降低对密码依赖的技术呢？比如说，使用银行 App，能不能就输一次密码，然后就可以不用再使用密码了。其实有很多这样的技术，比如手机的指纹识别、面部识别，都可以降低密码的输入频率。

如果你想系统地了解有关这方面最新的技术，我建议你从 2019 年 3 月 4 日发布的 WebAuthn 协议开始。深入阅读、理解这份协议，你可以掌握很多现代身份认证技术的概念和技术。了解了这些技术，像是银行 App 输入密码这种麻烦事，你就可能有比较靠谱的解决办法。

关于 WebAuthn 的具体技术细节和方案，请你去阅读 W3C 的协议，或者搜索相关的介绍文章。

@Tom

签名数据太大，比如文件图片，占用内存大，使用流处理可以减少内存占用吗？

答：签名数据可以分割开来，一段一段地传递给签名的接口。 比如要分配一个 2048 个字节的数组，每次从文件中读取不多于 2048 个字节的数据，传递给 Signature.update()。文件读取结束，再调用 Signature.sign() 方法完成签名。这种方法只需要在应用层分配一块小的内存，然后反复使用。

不太清楚你说的流处理是什么意思。如果一个字节一个字节或者一小块一小块地读取文件数据，就会涉及太多的 I/O。虽然节省了内存，但是 I/O 的效率可能就成了一个问题。

这个数组的尺寸多大合适呢？这和具体的签名算法，以及文件 I/O 有关系。目前来看，2048 个字节是一个常用的经验值。

问题（第 24 篇）：延迟分配的例子中，为什么要使用 temporaryMap 变量以及 temporaryMap.put() 而不是 helloWordsMap.put()？

为了方便阅读，我把这段要讨论的代码拷贝到了下面：

public class CodingExample {

    private volatile Map<String, String> helloWordsMap;

    private void setHelloWords(String language, String greeting) {

        Map<String, String> temporaryMap = helloWordsMap;

        if (temporaryMap == null) {    // 1st check (no locking)

            synchronized (this) {

                temporaryMap = helloWordsMap;

                if (temporaryMap == null) {    // 2nd check (locking)

                    temporaryMap = new ConcurrentHashMap<>();

                    helloWordsMap = temporaryMap;

                }

            }

        }

        temporaryMap.put(language, greeting);

    }

    // snipped

}

@yang

使用局部变量，可以减少主存与线程内存的拷贝次数。

@轻歌赋

双检锁在多 CPU 情况下存在内存语义 bug，通过 volatile 实现其内存语义。

@唐名之

使用局部变量，可以减少主存与线程内存的拷贝次数。这个点还是有点不明白能解释下嘛？

答：要解释这个问题，我们需要了解 volatile 这个关键字，要了解 volatile 这个关键字，就需要了解计算机和 Java 的内存模型。这些问题，在杨晓峰老师的《Java 核心技术 36 讲》和郑雨迪老师的《深入拆解 Java 虚拟机》专栏里，都有讲解。详细的技术细节，请参考两位老师的文章（点击链接即可直接看到文章）。

简单地说，线程的堆栈、CPU 的缓存、计算机的内存，可以是独立的物理区域。共享数据的读取，要解决好这些区域之间的一致性问题。也就是说，不管从线程堆栈读写（线程内），还是从 CPU 缓存读写（线程间），还是从计算机的内存读写（线程间），对于每个线程，这些数据都要一样。这就需要在这些不同的区域之间做好数据同步。

我们再来看这个例子。声明为 volatile 的 helloWordsMap 是一个共享的资源。它的读写，需要在不同的线程间保持同步。而同步有损效率。有没有办法降低一点读写的频率呢？

如果我们不使用共享的资源，也就没有了数据在不同内存间同步的需求。temporaryMap 变量是一个方法内的局部变量，这个局部变量，只在这个线程内起作用，不需要和其他线程分享。所以，它的访问就不存在同步的问题了。

把共享的 volatile 变量的引用，赋值给一个局部的临时变量，然后使用临时变量进行操作，就起到了降低共享变量读写频率的效果。

这种办法有一个适用场景，就是 volatile 变量的引用（地址）一旦初始化，就不再变更。如果 volatile 变量的引用反复变更，这种办法就有潜在的数据同步的问题了。

以上就是答疑篇的内容。如果这些问题是你和朋友，或者同事经常遇到的问题，不妨把这篇文章分享给他们，一起交流一下。

从下一篇文章起，我们就要开始这个专栏的第三部分“安全的代码”的学习了。在这一部分，我们将主要采用案例分析的形式来进行学习。下一篇文章见！

31 | 为什么安全的代码这么重要？

从今天开始，我们进入本专栏的“安全模块”。首先，我们通过一个具体的安全漏洞的案例，来感受下计算机代码是多么的脆弱，以及编写安全的代码为什么如此重要。

评审案例

在 Web 开发中，“multipart/form-data“类型经常被用来上传文件。比如下面这段描述表单的代码，就是使用 multipart/form-data 上传文件的一段 HTML 代码。

 <FORM action="http://upload.example.com/"

       enctype="multipart/form-data"

       method="post">

   <P>

   Upload the file: <INPUT type="file" name="upload-file"><BR>

   <INPUT type="submit" value="Send">

 </FORM>

文件上传的操作，会被浏览器解析成类似下面的 HTTP 请求。

Content-Type: multipart/form-data; boundary=AaB03x

--AaB03x

Content-Disposition: form-data; name="upload-file"; filename="myfile.txt"

Content-Type: text/plain

... contents of myfile.txt ...

--AaB03x--

Web 服务器接收后，会解析这段请求，然后执行相关的操作。下面的这段代码，是 2017 年 3 月之前 Apache Struts 2 解析“multipart”请求的实现。

其中，蓝色标注的代码，LocalizedTextUtil.findText()，用来查找错误的本地化信息。如果“multipart”请求解析出错，就会触发这个方法。它的规范大致如下：

对于 LocalizedTextUtil.findText() 的规范，我们要留意蓝色字体的部分。这一部分告诉我们，如果信息里包含了 OGNL（Object Graph Navigation Language）的表达式，表达式会被执行。

我们把上面的信息放到一块儿来看看：如果“multipart”请求解析出错，会调用 LocalizedTextUtil.findText() 来查找本地化的错误信息；如果错误包含 OGNL 表达式，表达式会被执行，以获取解释后的信息；本地化的错误信息会返回给请求者（比如浏览器）。

能不能构造一个包含 OGNL 表达式的“multipart”请求？对于熟悉 HTTP 协议和 OGNL 表达式的用户来说，这是一件轻而易举的事情。如果“multipart”请求不合法，OGNL 表达式会被执行，执行的结果以错误信息的形式返回给请求者。

通过“巧妙地”设计 OGNL 表达式，攻击者可以定制执行的指令，从而定制返回错误信息的内容。这样，攻击者几乎可以获得任何他想要的有价值的内部信息。这就是一个由代码引起的安全漏洞。这个安全漏洞的危险等级是 10.0 分（请参见下一节“如何评估代码的安全缺陷”），是一个危险等级最高的漏洞。

我们回头看 FileUploadInterceptor.intercept() 的这段实现代码时，它的危险性其实很清楚，主要有两点：

没有充分了解调用接口（LocalizedTextUtil.findText()）；

允许执行远程请求的表达式（OGNL 表达式）。

这两点分别违反了下面的安全编码原则：

清楚调用接口的行为；

跨界的数据不可信任。

真正的威胁

我们一起来看看这个漏洞的几个关键时间点：

2017 年 1 月 29 日，NIST 的 NVD（National Vulnerability Database ）接收到了这个漏洞报告。

2017 年 3 月 6 日，GitHub 上出现了漏洞的描述和攻击示例。

2017 年 3 月 7 日，Apache Struts 发布了这个漏洞的修复版本，Struts 2.3.32 和 2.5.10.1。

2017 年 3 月 7 日以及随后的几天，出现了更多的攻击示例，很多媒体和专家开始分析这个漏洞，推荐可能的漏洞防范措施，提醒升级 Apache Struts 到安全的版本。

我们要特别留意两段时间，第一段时间是 1 月 29 日到 3 月 7 日。这一段时间，安全漏洞已经被发现，但是并没有被公开。这说明这个安全研究者极有专业素养。我猜想，这位名字叫“Nike Zheng”的研究者，在 2017 年 1 月 29 日之前，把他的研究成果通知了 Apache Struts。然后双方共同努力，将这个漏洞一直保密到 2017 年 3 月 7 日。这一段时间的保密工作非常重要，要不然漏洞修复之前，会有大批的应用暴露在黑客的攻击之下。

寻找并且通知受到安全漏洞影响的软件供应商，然后双方共同保密一段时间，给漏洞修复留出足够的时间，这是安全研究者的通常做法。如果你认真学习了本专栏的“安全”模块，发现现存代码的安全问题，并且构造出可行的攻击方案，并不是一件特别困难的事情。如果以后你通过阅读代码，发现了一个漏洞，公布漏洞之前，请务必联系代码的维护者，做好漏洞的保密工作，并给他们预留充足的修复时间。

第二段时间是 2017 年 3 月 7 日，这一天漏洞的修复版本发布，漏洞的补丁公之于众，漏洞的细节也就随之公开。专业的研究者和黑客会迅速地解剖漏洞，研究攻击方式。留给应用系统的时间并不多，一定要想方设法在最短的时间内，升级到修复版本。做到这一点并不容易。大部分有效的安全攻击，都是发生在漏洞公布之后，修复版本升级之前。这一段时间，是最危险的一段时间。

Equifax 的教训

2017 年 9 月 7 日，美国最大的征信公司 Equifax 宣称，7 月 29 日公司发现遭遇黑客攻击，该攻击始于 5 月中旬，大约有 1.45 亿条信用记录被盗取，其中包括 20 多万用户的支付卡信息。在美国，包括社会保障号、出生日期在内的信用记录是高度敏感的信息。有了这些信用记录，一个人不用出面，甚至不需要支付一分钱，就可以买车、买房、申请信用卡。

果然，有人报告自己被冒名顶替买了车、买了宠物。对于一个依靠安全生存的公司，这种情况的发生无疑是令人沮丧的。随后的几天时间里，Equifax 的股票下跌超过了 30%，蒸发了折合大概 60 亿美元的市值。

是什么样的安全漏洞导致了这么大的损失？Equifax 公司后来确认，引起黑客攻击的漏洞，最主要的就是我们上面讨论过的 Apache Struts 漏洞。

Apache Struts 于 2017 年 3 月 7 日发布了针对该漏洞的修复版本。但是 Equifax 一直到 7 月底，都没有完成安全版本的升级，将自己敞露在风险之下。

从 3 月漏洞细节公布，到 5 月中旬，黑客用了两个月的时间，设计了攻击方案；然后，从 5 月中旬到 7 月底，又用了两个多月的时间，从容地获取了数亿条信用记录。

如果按照严重程度来算，这一次黑客攻击可以排进 21 世纪已知的重大信息安全事故的前三名。而且，这次安全事故的影响范围，远远超出 Equifax 公司本身。

人们对征信公司的信任，降低到了前所未有的程度，纷纷冻结自己的征信记录，不允许任何人查询；银行的信用部门，必须更加谨慎地防范信用欺诈，要投入更多的财力、人力。所有受到影响的用户，必须采取更加严格的措施保护自己在其他征信机构、金融机构、保险机构的信用状态。

所有的这些问题，归根到底，都是因为没有及时地完成安全修复版本的升级。这里面固然有技术的问题，但更多的是管理的问题。2017 年 9 月 15 日，Equifax 的首席信息官和首席安全官宣布退休。

五行不起眼的代码，酿造了一起损失数十亿美元的安全事故。受到影响的人群，也可能包括这个漏洞的研究者和修复者，系统的运营者，甚至是攻击者本人。这种不对称的破坏性让人唏嘘，这也正是我们为什么要重视代码安全的背后的原因。

Equifax 的教训给我们带来三点启示：

不起眼的代码问题，也可以造成巨大的破坏；

安全修复版本，一定要第一时间更新；

安全漏洞的破坏性，我们很难预料，每个人都可能是安全漏洞的受害者。

编写安全的代码

一般来说，安全的代码是能够抵御安全漏洞威胁的代码。

传统上，我们说到信息安全的时候，最常接触的概念是防火墙、防病毒、防攻击。其实，大部分的安全事故（80%-90%）是由软件的代码漏洞引起的。没有安全保障的代码，是随时都可以坍塌的空中楼阁。

小结

通过对这个案例的讨论，我想和你分享下面三点个人看法：

不起眼的小问题，也会有巨大的安全缺陷，造成难以估量的损失；

编写安全的代码，是我们必须要掌握的基础技能；

安全问题，既是技术问题，也是管理问题。

下一节，我们接着聊安全漏洞的威胁该怎么衡量。再接着，我们来讨论一些常见的编写安全代码的原则和实践。

一起来动手

Equifax 公司的问题之一，就是没有及时地更新安全修复。这一般不是疏漏的问题，而是没有充分认识到安全更新的重要性，或者没有把安全修复的计划执行到位。

要想升级到安全修复的版本，我们需要知道两件事：

第一时间获知，某个依赖的软件有了安全更新；

最快速地行动，升级到安全修复版本。

有时候，安全版本升级之前，安全漏洞的细节就已经暴露出来了。这时候，我们也要采取必要的措施：

第一时间知道出现了安全漏洞；

快速寻找、部署漏洞修复的临时方案。

人力总是有限的，我们接触到的信息也是非常有限的。上面的两种措施中，人工都没有办法做到第一点的，除非你使用的是一个完全封闭的系统（完全封闭的系统，一般也是漏洞更多的系统）；而第二点，或多或少的，都需要人工的参与。

我们利用讨论区，来讨论三个问题：

第一个问题是，你有没有使用最新版本软件的习惯？

第二个问题是，你的公司是如何获取安全漏洞信息和安全更新信息的？

第三个问题是，你的公司有没有安全更新的策略？如果有，又是怎么执行的，能不能执行到位？

欢迎你在留言区留言、讨论，我们一起来学习、思考这些老大难的问题！

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

32 | 如何评估代码的安全缺陷？

我自己有一点小小的强迫症，遇到事情，喜欢自己动手整个清楚明白。我的大部分失眠，都要拜这点强迫症所赐。时间永远都不够用。如果上天给我一个机会，我是不是可以借上五百年？其实，借上五百年，时间一定还是不够用的。

我经常被问到三个问题：

有什么事情是你必须要做的？

哪些事情是只有你能做的?

哪些事情是别人可以帮你做的？

这就是一种时间管理的思路，隐含的意思是：

识别并且选择最重要的事情；

确定自己最擅长的事情，全力以赴地做好；

选择你的帮手，充分信任并授权。

评估软件的缺陷就是这个思路运用得最广泛的一个场景。作为程序员，我们需要了解软件存在的问题，以及问题的严重程度。那么，我们该如何评估软件存在的问题，以及代码的安全问题呢？

关注用户感受

软件缺陷的定义方式和衡量方式有很多种。从用户感受的角度出发，定义和计量软件缺陷，是其中一个比较好的、常用的软件缺陷评估体系。我个人比较倾向一种观点，软件缺陷的严重程度应该和用户的痛苦程度成正比。

从用户感受出发，衡量软件缺陷有两个最常用的指标：

缺陷影响的深度，软件缺陷带来的问题的严重性：软件缺陷导致的最严重的问题是什么？

缺陷影响的广度，软件缺陷带来问题的可能性：软件缺陷导致问题出现的频率是多大？

比如说，一个外卖系统只要订餐金额超过 32766 元就无法提交订单，因为软件系统允许的最大金额为 32766 元。这种事情 一旦发生，就是一个非常严重的错误（订单无法提交）（影响的深度）。但是，在外卖系统里，金额超过 3 万元的订单数量应该不多，该错误发生的几率并不大（影响的广度）。

如果我们把每个指标都划分高、低两种程度，就能得到如下四种情况：

高严重性，高可能性；

高严重性，低可能性；

低严重性，高可能性；

低严重性，低可能性。

依据这四种情况，我们就可以定义软件缺陷的优先等级了：

高优先级 （P1）： 高严重性、高可能性；

中优先级 （P2）： 高严重性、低可能性； 低严重性、高可能性；

低优先级 （P3）： 低严重性、低可能性。

上述外卖系统的软件缺陷的优先级应该是中等优先级（P2，高严重性、低可能性）。

缺陷，需要从外向内看

假设这个外卖系统有一个 bug，订餐金额可以是 0 或者负数。如果订餐金额是负数，餐厅不仅需要送餐上门，还需要倒贴钱。这可是一个好玩的 bug，当然不能坐视不管。

过一段时间，bug 修好了。大部分餐厅都能接受新系统。可是有一个商家表达了不同意见。原来他们有一个“寻找美食家”的活动，餐厅不仅不要顾客的钱，还倒贴同等餐费，请客人品尝最新餐品。负数金额，正是他们倒贴钱的“定价”。 美其名曰“你尝，我负”。该商家声称，新系统存在一个严重的缺陷，定价无法使用负数，导致这个活动无法进行。

如果你是这个外卖系统的工程师，这个 bug 你修不修？

你是否认可和接受这个缺陷报告，背后反映的就是你看待这个软件缺陷的态度。如果从用户感受的角度出发，定义和计量软件缺陷，这就是一个符合条件的 bug。这个问题对该商家的影响非常大，无法开展正常的商业活动。但这个问题发生的可能性比较小，大概只有这么一个商家使用负数定价。那么这个软件缺陷的优先级是中等优先级（P2，高严重性、低可能性）。

你看， 这个外卖系统的代码变更本身并不存在真正的缺陷，但是，如果从用户角度来看，又的确存在一个中等优先级的缺陷。我们当然可以认为，这个缺陷应该在用户那里得到校正。但是有时候，用户并没有校正这种缺陷的机会和能力。

你会不会觉得这个例子有点离奇、离谱，甚至有点搞笑？其实处理这种事情，是我日常工作中非常重要的一部分。 一旦 Java 的接口规范和规范实现发布，我们并不知道在现实世界中，用户如何运用他们的聪明才智，发挥他们的创造性，灵活地使用这些接口和实现。而无论用户怎么使用，在软件升级变更中，我们都没有充分的理由打断用户的应用运转和商业运营。所以软件的升级或者变更，处处充满了乐趣和挑战，步步惊心。

在一个好的软件缺陷评估体系中，不是只有代码错误才会被关注，没有错误的代码，也可能存在需要变更或者改进的“缺陷”。这就是我们要强调的，从用户的感受出发，定义和计量软件缺陷。缺陷，需要从外向内看。

很多时候，程序员认为是严重的缺陷，用户可能一点儿都感受不到；程序员认为无关紧要的事情，放到了用户的使用场景中，可能就是非常严重的事故。从外向内看缺陷，要求我们站在用户的角度思考问题，看待缺陷。这是一个可以让我们深切关注用户感受的视角。从用户视角出发的决策，可以让我们的时间使用得更有市场价值。

细化的优先级

在一定程度上，作为软件的原作者或者维护者，我们被各种各样的软件缺陷包围着，永远存在修补不完的缺陷，永远存在无法修复的问题。上述软件缺陷的优先等级的定义，稍显粗糙。我们可能需要更细致一些的等级划分，以便更好地安排我们的时间和区分做事情的轻重缓急。

如果我们把每个指标都划分高、中、低三种程度，就可以得到九种情况，定义五种优先等级。五种等级，是一个常用的优先级数目。太少了，显得粗糙；太多了，容易迷糊。

第一优先级 （P1）： 高严重性、高可能性；

第二优先级 （P2）： 高严重性、中可能性；中严重性、高可能性；

第三优先级 （P3）： 高严重性、低可能性；中严重性、中可能性；低严重性、高可能性；

第四优先级 （P4）： 中严重性、低可能性；低严重性、中可能性；

第五优先级 （P5）： 低严重性、低可能性。

你自己试试看，上面我们讨论过的外卖系统的软件缺陷属于第几优先级？

优先级的灵活性

软件缺陷优先等级的定义是为了帮助我们更好地解用户的感受程度，以及安排时间和处理事情。

由于时间和资源有限，在大多数情况下，特别是对于职业的程序员来说，并不能在一定时间内修复所有的缺陷，满足所有的变更要求。

实际工作中，我们有时候需要调节软件缺陷的优先等级，比如说：

如果已经存在应对办法，优先等级可以下调；

如果软件缺陷引起广泛的公开关注，优先等级应该上调；

如果软件缺陷影响了重要的客户，优先等级应该上调；

如果软件缺陷影响了重要的商业运营，优先等级应该上调。

对于一般的软件缺陷管理，五个等级是一个恰当的优先级分割。然而，除非特别注明，仅从优先级别来看，我们并不清楚 P3 缺陷的严重性是高是低，或者发生的可能性是高是低，而且问题的严重性在哪儿体现，可能性又是如何度量的？ 这些也都是模糊的地方，可能受主观影响比较大。但是有一些软件缺陷，需要对这些问题有一个更加清晰的认识和感受，比如软件安全漏洞。应该如何评估软件的安全漏洞？ 我们会在稍后的接着聊这个话题。

管理好自己的时间

好了，我们定义了软件缺陷的优先级，是时候看看如何使用它管理我们的时间了。还记得开头提到的三个问题吗？

1. 有什么事情是你必须要做的？

P1 的事情需要我们立即全力以赴、必须完成；P2 的事情需要我们协调资源，尽快完成；P3 的事情需要我们密切关注，尽量完成。

2. 哪些事情是只有你能做的?

只有你能够修复的 bug，你可以记到自己名下，负责修复这些缺陷。

3. 哪些事情是别人可以帮你做的？

适合别人修复的 bug，如果还没有记到别人名下，你可以琢磨下谁是最合适的人选，然后和他商量，看他有没有时间，愿不愿意负责这个缺陷。当然，别人也可能会问你愿不愿意修复另外一些缺陷。

相信我，大部分情况下，在得到足够的尊重以及有适当时间的前提下，人们愿意做些有意义的事情。

如果 P1、P2、P3 的问题修复完了，你就可以放心休假去了。休完假，充分从疲劳中恢复过来后，你就可以考虑，是不是可以看看 P4 和 P5 的问题了。

安全漏洞，需要大范围协作

在软件缺陷中，安全漏洞是一个奇异的存在。软件的安全漏洞，常常会导致非常严重的后果，以及恶劣的影响，甚至会直接导致一个公司的破产。

由于编写安全代码本身的挑战性，以及消除安全漏洞的复杂性，业界通常需要进行大范围的合作，以便准确、快速、周全地解决安全缺陷问题。大规模协作需要标准的描述语言，以及对安全问题的准确认知。通用缺陷评分系统（CVSS）就是一种评判安全缺陷优先等级的标准。

对于安全缺陷，我们还可以使用上面提到过的严重性和可能性两种指标进行衡量。对这两种指标进行细化，才能更符合安全缺陷的特点。

对于安全缺陷的严重性，有四个互相独立的测量维度（量度）：

对私密性的影响（Confidentiality）

对完整性的影响（Integrity）

对可用性的影响（Availability）

对授权范围的影响（Authorization Scope）

题外话：
私密性、完整性以及可用性，是描述信息安全的最基本的三个元素。
私密性指的是数据未经授权，不得访问，解决的是“谁能看”的问题。
完整性指的是数据未经授权，不得更改，解决的是“谁能改”的问题。
可用性值得是数据经过授权，可以访问，解决的是“可以用”的问题。

对于安全缺陷的可能性，有四个互相独立的测量维度（量度）：

安全攻击的路径（Attack Vector）

安全攻击的复杂度（Attack Complexity）

安全攻击需要的授权（Privileges Required）

安全攻击是否需要用户参与（User Interaction）

由于这些测量维度都是相互独立的，二维的平面图已经不足以表示这么多维度了。通用缺陷评分系统使用了标识符系统和计分系统，通过标识符来标识测量维度的指标，通过十分制的计分来衡量安全问题的严重程度。由于测量维度的增多以及评分计算的复杂性，我们通常使用工具来记录和查看安全缺陷问题的等级。

比如，本文评审案例的那个缺陷，并不是一个安全问题。 如果我非要使用通用缺陷评分系统来描述它，这个计分应该是 0.0 分，直观描述看起来如下图：

我们曾经谈到过"goto fail"这个安全问题，如果使用通用缺陷评分系统，计分是 7.2，直观描述如下图所示：

今天我给你介绍了通用缺陷评分系统的一些最基本的概念，先帮你形成一个基本的印象，算是一块敲门砖。你可以进一步了解通用缺陷评分系统的有关规范和工具。

安全漏洞和软件缺陷优先级

为了方便管理，安全漏洞和软件缺陷通常使用同一个代码缺陷管理系统。我们要注意两点：第一点是安全漏洞细节不可泄露；第二点是和普通软件缺陷相比，安全漏洞要优先考虑。

安全漏洞细节不可泄漏

我们反复强调过，软件的安全漏洞常常会导致非常严重的后果，以及恶劣的影响。最糟糕的是，我们并不能总是预料到谁可以利用这些漏洞，以及由此带来的后果有多严重。所以处理安全漏洞的态度，一定要保守。

安全漏洞不能像普通的代码缺陷那样，可以公开细节、公开讨论。相反，安全漏洞的知情人员一定要控制在一个尽可能小的范围内，知道的人越少越好。如果安全漏洞和普通缺陷共享一个代码缺陷管理系统，一定要想办法做好安全漏洞信息的权限管理。

安全漏洞要优先修复

一旦发现一个安全漏洞，不管是来源于外部情报，还是内部发现，我们都要考虑最快地修复，不要等待，更不要拖沓。即使我们全力以赴地修复，在系统修复之前，安全攻击随时都有可能发生。我们能做的，就是尽最大努力，缩短这段时间。

所以，大部分的安全漏洞问题，都是属于 P1 级别的缺陷。有一小部分深度防御的安全问题，优先级可以是 P2。安全问题，不要使用 P3 及以下优先级。另外在所有的同级缺陷中，安全问题要优先考虑。

小结

今天，我们讨论了如何评估软件存在的问题。软件的缺陷问题，要考虑缺陷影响的深度（严重性）和广度（可能性）。为了更好地认识安全漏洞，我们还要了解安全缺陷的评价标准。

有了软件缺陷的优先级，我们就可以更好地管理我们的工作和时间。下面的三个问题可以帮助你做好安排：

有什么事情是你必须要做的？

哪些事情是只有你能做的?

哪些事情是别人可以帮你做的？

一起来动手

我们要想掌握安全编码的技术，熟练修复软件漏洞的实践，需要先过三道关。

第一道关，是意识（Conscious）。也就是说，要意识到安全问题的重要性，以及意识到有哪些潜在的安全威胁。

第二道关，是知晓（Awareness）。要知道软件有没有安全问题，安全问题有多严重。

第三道关，是看到（Visible）。要了解是什么样的问题导致了安全漏洞，该怎么修复安全漏洞。

比如我们上一次谈到的 Equifax 公司的信用记录泄漏的安全问题，首席信息官和首席安全官之所以退休，我们从外部可以猜测到的原因，就是意识不够强烈，没有及时更新安全修复。之所以没有及时地更新，大概率是不知道有安全问题，或者不知道安全问题有多严重。

系统管理员，最少需要过两关（意识和知晓）；而软件开发工程师，需要过三关（意识、知晓和看到）。这三关并不容易过。

记得我们前面讨论的“安全漏洞细节不可泄漏”的实践吗？知道安全漏洞的人越少越好。这样保守的安全防范实践，和培养优秀的软件工程师所要求的实践，就成了悖论。

掌握编码安全技术，要培养意识，要知道得更多，要看到问题的细节。而“安全漏洞细节不可泄漏”的实践，却要求尽量不要公开安全细节。但是看不到细节的时候，我们就很难掌握这些技术，很难认识到威胁的严重性，从而阻碍了安全意识的培养。面对这一对几乎不可调和的矛盾，我们该怎么办？！

有数据披露，2019 和 2020 两年，全球信息安全专业人员的缺口会有 300 多万。资源不足，需求强劲，我们怎样才可以学好安全代码的编写技能，提升自己的价值？

欢迎你在留言区留言、讨论，分享你的经验。我们一起来学习、思考这些老难老难的现实问题！

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

33 | 整数的运算有哪些安全威胁？

在我的日常工作中，有一类错误，无论是原理还是后果，我都十分清楚。但是写代码的时候，这类错误曾经还是会反复出现。如果不是代码评审和代码分析环节的校正，我都很难意识到自己的代码中存在这样的缺陷。今天，我想和你聊聊，那些“道理我都懂，但代码就是写不好”的老顽固问题。

你不妨先停下来想一想，你有没有类似的经历？ 又是怎么解决的呢？

评审案例

HTTP 连接经常被中断或者取消，如果客户端已经获得一部分数据，再次连接时，应该可以请求获取剩余的数据，而不是再次请求获取所有数据。这个特性背后的支持协议就是 HTTP 范围请求协议（RFC 7233）。

比如下面的例子，客户端请求服务器返回 image.jpg 图像的前 1024 个字节。其中“bytes=0-1023”表示请求传输的数据范围是从 0 到第 1023 位的字节（0-1023），以及“-512”表示请求传输数据的最后 512 个字节（-512）。

GET /image.jpg HTTP/1.1

Host: www.example.come

Range: bytes=0-1023,-512

如果服务器支持该协议，就会只传输图像的指定数据段。响应消息的代码大致如下所示：

HTTP/1.1 206 Partial Content

Content-Type: multipart/byteranges; boundary=THIS_STRING_SEPARATES

Content-Length: 2345

--THIS_STRING_SEPARATES

Content-Type: image/jpeg

Content-Range: bytes 0-1023/2048

...

(binary content)

--THIS_STRING_SEPARATES

Content-Type: image/jpeg

Content-Range: bytes 1535-2047/2048

...

(binary content)

--THIS_STRING_SEPARATES--

如果服务器端使用下属的代码验证请求数据的指定数据段（C 语言），你来看看可能存在什么严重的问题？

/**

 * Check if the requested range is valid.

 *

 * start: the first byte position of the range request

 * end: the end byte position of the range request

 * contentLength: the content length of the requested data

 * sum: the sum of bytes of the range request

 */

bool isValid(int start, int end, int contentLength, int* sum )) {

    if (start < end) {

        *sum += end - start;

        if (*sum > contentLength) {

            return false;

        } else {

            return true;

        }

    } else {

        return false;

    }

}

案例分析

上面的代码简化自 Nginx HTTP 服务器关于 HTTP 范围请求协议在 2017 年 6 月份的实现版本。由于讨论的需要，我较大幅度地删减和修改了原来的代码。如果有兴趣，你可以阅读并比较2017 年 7 月份的实现版本和2017 年 6 月份的实现版本，看看都有哪些比较有意思的改动。

上面的代码有许多问题，其中有一个致命的魔鬼藏在下面的一行代码中：

        *sum += end - start;

我们假设数据长度为 1024 个字节，HTTP 请求的范围有两段，第一段的请求字节数是一个正常的数据 （比如，1023），而第二段的请求字节数的是一个巨大的数据（比如，INT_MAX - 23），以至于两段数据相加时，发生了整数溢出。本来应该是一个很大的数，可是这个数超出了整数可以表达的范围，结果就发生整数溢出，变成了一个很小的数 （比如，（1023 + INT_MAX - 23），结果是 999 或者 -2147482649。这样就通过了上述的 HTTP 请求范围验证。

第一段数据也许可以正常使用。然而，由于实际的数据长度不足以满足第二段数据范围请求，就有可能出现非常复杂的状况。 比如说，为了加快反应速度，提高服务器效率，很多 HTTP 服务器都使用了缓存技术，Nginx 也不例外。

如果使用缓存技术，数据可能并不是直接从原始数据源读取，而是读取缓存的数据，而缓存的数据是一个临时的大集合，可能包括各种各样的数据，包括敏感数据。

如果发生读取范围溢出，目标数据段之外的缓存数据可能被读取。而目标数据段之外的数据，可能并没有授权给这个用户使用，这样就可能发生敏感信息的泄露。甚至通过设计，攻击者也有可能更改目标数据以外的非授权数据。这样就间接地操纵了服务器。

关于这个安全漏洞的更多描述，请参阅CVE-2017-7529。这是一个通用缺陷评分系统评分为7.5的严重安全缺陷。由于 Nginx 的广泛部署与使用，该漏洞影响了一大批安装了 Nginx 服务器的系统。

出问题的表达式使用太普遍了，谁能想到会出现这么严重的安全漏洞呢。我们感兴趣的问题是：整数的加法，如此普遍的运算，如果都这么脆弱，到底该怎么办才好？

整数的陷阱

一个优秀的语言，一定是便于学习和使用的语言。语言语法的设计通常会考虑我们的现有习惯，降低使用门槛。比如，加法运算通常会设计成我们最熟悉的样子：

  a = b + c;

我们如此熟悉这种表达式，一旦需要加法运算，这种表达式一定首先从我们的脑袋里自动跳出来。我们毫无察觉，也不会有意识地去追究这样一个从学习算数起就开始使用的表达式到底有什么问题。

那么到底有什么问题呢？

第一个问题是，我们使用上述表达式时，每个数字都可以是无限大的；然而在计算机的世界里，数字大小都是有限制的。比如，整数一般使用 32 位（bit），或者 4 个字节（byte）来表示，整数的大小不能超过 32 位能够容纳的范围。

整数运算，可能溢出

如果我们使用一位（bit）来表达一个整数，那么通常就会是下面这种情况：

 0 + 0 = 0

 0 + 1 = 1

 1 + 1 = 0

注意，1 + 1 的结果不是我们习以为常的 2，而是 0。这是为什么呢？ 因为 2 太大了，一位的空间不够用的，所以就“溢出”了。“溢出”导致这个运算的结果是 0，而不是预想的 2。

再比如说，我们表示时间的时候，如果采用 12 小时制，12 点过一个小时就是 1 点，而不是 13 点；如果采取 24 小时制，24 点过两个小时就是 2 点钟，而不是 26 点。

如果我们对于一位的数，以及 24 小时制还算“清醒”的话，那么对于 32 位或者 64 位的数，可能就没那么重视了。你的代码里有没有涉及到整数的运算？有没有潜在的溢出问题？ 我是经常会掉入这个陷阱的。

整数溢出的问题，曾经在 1995 年导致火箭的坠落； 在 2016 年导致错误地签发了四千多万美元（最高限额原为 1 万美元）的博彩奖券。还有绵延不绝的，你我知道抑或不知道的软件安全漏洞。

我们更关心的是，该怎么避免这类错误？

首先最重要的，是要借助软件开发的机制，减少代码错误。比如我们在专栏开始讲的借助重重关卡减少错误。虽然我在编写代码的时候会时常忘却这个问题，但在评审代码时，有时候还能够记住这个问题的危害。多一双眼睛，就多了一处关卡。

然后，还要了解一些小技巧，我们看看都有哪些？

1.比较运算，选择“比较小的数”。

如果表达式出现在比较运算符的两侧，选择产生较小的数的运算。比如下面这段代码：

// a, b, c are positive integers

if (a < (b + c)) {    // (b + c) can overflow

    // snipped

}

// a, b, c are positive integers

if ((a - b) < c) {    //  no overflow

    // snipped

}

这个例子适用于正数的运算，如果是负数呢？ 如果不确定是整数还是负数，该怎么办？这个问题，我留给你去思考。

2.限定数的范围，选择冗余的空间。

如果现实需要 32 位的整数，就选择 64 位的存储空间（也就是说，使用 64 位的整数类型）进行运算。如果现实需要 31 位的整数，就选择 32 位的存储空间。限定了数的范围，一定要记得检查数据的范围，千万不可超越这个范围。

private static int MAX_DATA_SIZE = 16384;  // 2^14 bytes at mosts

private final ByteBuffer cache =

    ByteBuffer.allocate)(MAX_DATA_SIZE);   // limit the capacity

static int receive(byte[] data) {

    if (data == null || data.length == 0) {    // input check

        // snipped

    } else if (data.length > MAX_DATA_SIZE) {  // check the range

        // throw exception, snipped

    } else {

        if (data.length > cache.remaining()) { // check the add-up

            // throw exception, insufficient space, too much data

        }

        if ((data.length + 1024) > cache.remaining()) {

            // safe '+', as the numbers are limited to 2^14.

            // snipped

        }

        // snipped

    }

    // snipped

}

上面例子中的加法运算就是安全的。因为运算涉及的数据都被限定在 14 位范围内，而两个数相加，最多不超过 15 位。由于我们使用了 32 位的整数作为数据的类型，那么 15 位的数据就不会产生溢出问题。

但是，这种方法要求我们时刻绷紧神经，仔细地定义、检查每个数据的限定范围，对我们自身要求相对有点高。所以涉及到比较运算，我还是建议使用“比较小的数”的办法。

-           if ((data.length + 1024) > cache.remaining()) {

+           if ((data.length - cache.remaining()) > 1024) {

3.检查数据溢出。

检查数据溢出，虽然代码看起来有点多，但这总是一个有效可行的办法。比如，评审案例中的缺陷修复，就采取了类似如下的修改：

+       if (*sum > (NGX_MAX_OFF_T_VALUE - (end - start))) {

+           return false;

+       }

        *sum += end - start;

从 Java 8 开始，Java 提供了数据溢出保护的运算方法，比如 Math.addExact(int, int)，执行两个整数相加的运算，如果有整数溢出，就会抛出 ArithmeticException 的异常。这些方法也许并不如直接使用运算符直观，但是它们提供了额外的保护机制。如果我们不能确定溢出是否会发生，使用这些方法可以让我们的代码获得更加深度的保护。

    int sum = Math.addExact(a, b);   // sum = a + b

整数溢出的危害是整数太大了，超出了许可边界。如果整数还没有大到溢出的程度，但也足够大，同样是一个值得警惕的风险。

其他的语言也可能有类似的数据溢出保护方法，欢迎你在评论区留言分享。

整数，可能太大

不比数学世界里的整数，软件世界里的整数，大都具有现实的意义。 比如，整数可以代表人民币，可以代表美元，也可以代表文件长度，代表内存空间，代表运算能力。 一旦抽象的整数被赋予了现实意义，就会有现实的约束。比如，1 亿元人民币虽然是个小目标，但你要是用来发人手一份的红包，也许就有点大了。再比如，针对 32 位整数，虽然现代计算机已经可以毫无障碍地表达这个数据了，但要是用来分配应用内存，这个数就有点大了。

我们要特别警惕大量内存的动态分配。比如说很多协议的设计都会指定待传输数据的大小，而接收端需要按照指定的大小来接收紧接着的数据流。有时候需要分配内存，来存储、处理接收的数据。其中有一种实现，接收到指定大小的数据后，接收端再根据指定的大小分配内存，然后把后续的数据存储在该内存里。指定数据接收完毕，再开始处理该数据。你看出其中的问题了吗？

一个比较典型的安全攻击是，攻击者会设置非常大的待传输数据的大小（比如 2^31)，但是只传输非常小的数据（比如 1 个字节），然后在很短的时间内，发送多个请求（一个机器或者多个机器）。一个 16G 内存的服务器，如果有 8 个这样的请求，内存就红灯高挂了；有 10 个这样的请求，内存可能就要挂免战牌了。这就破坏了服务器的“可用性”，算是比较严重的安全事故。

好了，这就是今天的内容，算是关于数的问题的敲门砖，更多的、更深入的话题，可以阅读CWE-190，或者留言与我一起讨论。

小结

通过对这个评审案例的讨论，我想和你分享下面几点个人看法。

数值运算，理论结果可能会超出数值类型许可的空间，进而发生实际结果的溢出。

抽象的数据一旦有了现实意义，便有了具体的现实约束，我们一定要考虑这些约束。

很多问题和我们的习惯并不相符，要通过制度设置来减少由于人的固有缺陷带来的经常性问题。

一起来动手

我们一起讨论了一些整数的问题，你愿不愿意总结下浮点数的问题？ 我们使用了 C 语言和 Java 语言的示例，你了解其他语言关于整数溢出的技术和经验吗？

欢迎你来评审下面的这段 C 语言代码：

int copy_something(char* buf, int len){

    char kbuf[800];

    if(len > sizeof(kbuf)){

        return -1;

    }

    return memcpy(kbuf, buf, len);

}

或者这段 Java 代码：

public static int mixed(int addOn, int multiplied, int scale) {

    return addOn + (multiplied * scale);

}

或者，是下面这段我们已经非常熟悉的 Java 代码：

import java.util.HashMap;

import java.util.Map;

class Solution {

    /**

     * Given an array of integers, return indices of the two numbers

     * such that they add up to a specific target.

     */

    public int[] twoSum(int[] nums, int target) {

        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {

            int complement = target - nums[i];

            if (map.containsKey(complement)) {

                return new int[] { map.get(complement), i };

            }

            map.put(nums[i], i);

        }

        throw new IllegalArgumentException("No two sum solution");

    }

}

针对上面三段代码，你有什么改进的建议呢？可以在评论区与我分享你的想法。

另外，分享一个最近（2019 年 3 月）发生的和整数有关的安全事故。

安全起见，一个数字证书的序列号应该至少有 64 位随机数，少一位都不行。如果你对整数足够敏感的话，就会知道 64 位是一个特殊的位数。长整型（long）通常使用 64 位字节来表述。数字证书的序列号能不能使用 64 位的长整型呢？这就是个坑！

为了保证序列号是正数，64 位的长整型，只有 63 位有效的数字。因为，64 位长整型中，有一位是用来表示数据正负的。所以，长整型就不能用做数字证书的序列号。

这个坑，就是有人踩了。有数百万张数字证书仅使用了 63 位的随机数。按照业界规则，这些数字证书需要问题发现 5 天以内撤销，重新签发。这几乎是一项不可能完成的任务。2019 年 3 月和 4 月，很多公司都会面临数字证书更新的问题。

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事，一起来交流。

34 | 数组和集合，可变量的安全陷阱

在前面的章节里，我们讨论了不少不可变量的好处。在代码安全中，不可变量也减少了很多纠葛的发生，可变量则是一个非常难缠的麻烦。

评审案例

我们一起看下这段 JavaScript 代码。

var mutableArray = [0, {

    toString : function() {

      mutableArray.length = 0;

    }

  }, 2];

console.log("Array before join(): ", mutableArray);

mutableArray.join('');

console.log("Array after join(): ", mutableArray);

调用 mutableArray.join() 前后，你知道数组 mutableArray 的变化吗？调用 join() 前，数组 mutableArray 包含两个数字，一个函数 （{10, {}, 20}）。调用 join() 后，数组 mutableArray 就变成一个空数组了。这其中的秘密就在于 join() 的实现，执行了数组中 toString() 函数。而 toString() 函数的实现，把数组mutableArray 设置为空数组。

下面的代码，就是 JavaScript 引擎实现数组 join() 方法的一段内部 C 代码。

static JSBool

array_toString_sub(JSContext *cx, JSObject *obj, JSBool locale,

                   JSString *sepstr, CallArgs &args) {

    // snipped

    size_t seplen;

    // snipped

    StringBuffer sb(cx);

    if (!locale && !seplen && obj->isDenseArray() &&

        !js_PrototypeHasIndexedProperties(cx, obj)) {

        // Elements beyond the initialized length are

        // 'undefined' and thus can be ignored.

        const Value *beg = obj->getDenseArrayElements();

        const Value *end =

            beg + Min(length, obj->getDenseArrayInitializedLength());

        for (const Value *vp = beg; vp != end; ++vp) {

            if (!JS_CHECK_OPERATION_LIMIT(cx))

                return false;

            if (!vp->isMagic(JS_ARRAY_HOLE) &&

                !vp->isNullOrUndefined()) {

                if (!ValueToStringBuffer(cx, *vp, sb))

                    return false;

            }

        }

    }

    // snipped

}

这段代码，把数组的起始地址记录在 beg 变量里，把数组的结束地址记录在 end 变量里。然后，从 beg 变量开始，通过调用 ValueToStringBuffer() 函数，把数组里的每一个变量，转换成字符串。

我们一起来看看第一段代码，是怎么在这段 join() 实现的 for 循环代码里执行的。

vp 指针初始化后，指向数组的起始地址；

如果 vp 的地址不等于数组的结束地址 end，就把数组变量转换成字符串，然后变换 vp 指针到下一个地址 。我们一起来看看这段代码是如何操作数组 mutableArray 的：

a. 数组的第一个变量是 0。0 被转换成字符，vp 指针换到下一个地址；
b. 数组的第二个变量是 toString() 函数。toString() 函数被调用后，就会把 mutableArray 这个数组设置为空数组，vp 指针换到下一个地址；

c. 数组的第三个变量本来应该是 2。但是，由于数组在上一步被置为空数组，数组的第三个变量的指针指向数组外地址。

由于数组已经被设置为空数组，原数组的地址可能已经被其他数据占用，访问空数组外的地址就会造成内存泄漏或者程序崩溃。

通过设置第一段代码里的 mutableArray 和利用这个内存泄漏的漏洞，攻击者可以远程执行任意代码，获取敏感信息或者造成服务崩溃。这是一个通用缺陷评分系统评分为 9.9的严重安全缺陷。

案例分析

我们上面讨论的第一段代码里的 mutableArray 的构造方式，是一个典型的用于检查 JavaScript 引擎实现或者其他 JavaScript 数组使用缺陷的技术范例。

近十多年来，陆续发现了一些相似的 JavaScript 引擎数组实现的严重安全漏洞。几乎所有主流的 JavaScript 引擎提供商都受到了影响。我们太习惯使用数组的编码模式了，数组长度的变化，很难进入我们的考量范围。因此，查看或者编写这些实现代码，我们很难发现里面的漏洞，除非我们知道了这样的攻击模式。

如果一个新语言，支持类似 JavaScript 语言里这么灵活的函数数组变量，你可以试着找找这门编程语言实现里有没有类似的安全漏洞。

如果我们从根本上来看可变量，它的安全威胁就在于在不同的时间、地点里，可变量可以发生变化。如果编写代码时，意识不到不同时空里的变化，就会面临安全威胁。

我们再来看一下可变量的例子。在 Java 语言里，java.util.Date 是一个从 JDK 1.0 开始就支持的类。我们可以构建一个对象，来表示构建时的时间，然后再修改成其他时间。就像下面的这段伪代码这样。

public void verify(Date targetDate) {

    // Verify that a contract is valid in the day of targetDate.

    // <snipped>

    // Display that the contract is valid in the day of targetDate

}

void checkContract() {

    Date today = new Date();

    // create a new thread that modify the date to a new date.

    // For example, today.setYear(100) will reset the year to 2000.

    // verify that the contract is valid today.

    veify(today);

}

上面的代码中，verify() 方法和修改日期的线程间就存在竞争关系。如果日期修改在 verify() 实现的验证和显示之间发生，显示的日期就不是验证有效的日期。这就会给人错误的信息，从而导致错误的决策。这个问题就是 TOCTOU（time-of-check time-of-use）竞态危害的一种常见形式。

可变量局部化

类似于 TOCTOU 的安全问题，让 java.util.Date 的使用充满了麻烦。

那么该怎么防范这种漏洞呢？当然，最有效的方法就是使用不可变量。对于可变量的参数，也可以使用拷贝等办法把共享的变量局部化。由于可变量可以在不同时空发生变化，所以，无论是传入参数，还是返回值，都要拷贝可变量。这样共享的变量，就转换成了局部变量。

比如上面的例子，我们就可以改成：

public void verify(Date targetDate) {

    // Create a copy of the targetDate so as to avoid the

    // impact of any changes.

    Date inputDate = new Date(targetDate.getTime());

    // Verify that a contract is valid in the day of inputDate.

    // <snipped>

    // Display that the contract is valid in the day of inputDate

}

void checkContract() {

    Date today = new Date();

    // create a new thread that modify the date to a new date.

    // For example, today.setYear(100) will reset the year to 2000.

    // verify that the contract is valid today.

    // Use a clone of the today date so as to avoid the impact of

    // any changes in the verify() implementaiton.

    veify((Data)today.clone());

}

不知道你有没有注意到，在 veirfy() 的实现里，我们使用了 Date 的构造函数来做拷贝；而在 checkContract() 的实现里，我们使用了 Date 的 clone() 方法来做拷贝。为什么不都使用更简洁的 clone() 方法呢？

在 checkContract() 的实现里，today 变量是 Date 类的一个实例。我们都了解，Date 类的 clone() 方法的实现，的确做到了日期的拷贝。而对于作为参数传入 verify() 方法的 targetDate 对象，我们并不清楚它是不是一个可靠的 Date 的继承类。这个继承类的 clone() 实现，有没有做日期的拷贝，我们也不知晓，因此，targetDate 对象的 clone() 方法，不一定安全可靠。所以，在 verify() 实现里，使用 clone() 拷贝传入的参数，也不可靠。

类的继承还有很多麻烦的地方，后面的章节，我们还会接着讨论继承的安全缺陷。

支持实例拷贝

在一定的场景下，安全的编码需要通过拷贝把可变变量局部化。这也就意味着，我们设计一个可变类的时候，需要考虑支持实例的拷贝。要不然，这个类的使用，可能就会遇到无法安全拷贝的麻烦。

实例的拷贝，可以使用静态的实例化方法，或者拷贝构造函数，或者使用公开的拷贝方法。需要注意的是，如果公开的拷贝方法可以被继承，继承类的实现方式就不可预料了。那么，这个公开的拷贝方法的使用就是不可靠的。支持公开的拷贝方法，一般只适用于 final 公开类。

静态的实例化：

public static MutableClass getInstance(MutableClass mutableObject) {

    // snipped

}

拷贝构造函数：

public MutableClass(MutableClass mutableObject) {

    // snipped

}

公开拷贝方法：

public final class MutableClass {

    // snipped

    @Override

    public Object clone() {

        // snipped

    }

}

禁用拷贝方法：

public class MutableClass {

    // snipped

    @Override

    public final Object clone() throws CloneNotSupportedException {

        throw new CloneNotSupportedException();

    }

}

浅拷贝与深拷贝

实现拷贝，一般有两种方法。

一种是拷贝变量的指针或者引用，并不拷贝变量指向的内容。拷贝和原实例共享指针指向的内容，如果拷贝实例里的变量指向的内容发生了改变，原实例里的变量指向的内容也随着改变。这种拷贝方法通常称为浅拷贝（shallow copy）。

另外一种方式，是拷贝变量指向的内容。拷贝后的实例和原有的实例中，变量指向的内容虽然相同，但是相互独立的。一个实例里，变量指向的内容发生了改变，对另外一个实例没有影响。这种拷贝方法通常称为深拷贝（deep copy）。

对于一个类里的不可变量，一般我们使用浅拷贝就可以了。这也是不可变量的又一个优点。

下面的这段代码，就混合使用了可变量、不可变量，以及浅拷贝和深拷贝技术，来实现实例拷贝的一个示例。

拷贝

final class MyContract implements Cloneable {

  private   String title;

  private   Date signedDate;

  private   Byte[] content;

    // snipped

    @Override

    public Object clone() throws CloneNotSupportedException {

        MyContract cloned = (MyContract)super.clone();

        // shallow copy, String is immutable

        cloned.title = this.title;

        // deep copy, Date is mutable

        cloned.signedDate = new Date(this.signedDate.getTime());

        // deep copy, array are mutable

        cloned.content = this.content.clone();

        return cloned;

    }

}

浅拷贝和原实例共享指针指向的内容，拷贝实例和原实例都可以改变指向的内容（除非内容为不可变量），这样就影响了对方的行为。所以，可变量的浅拷贝并不能解除安全隐患。

由于有两种拷贝方法，而且不同的拷贝方法适用的范围有一定的区别，我们就需要弄清楚一个类支持的是哪一种拷贝方法。特别是如果一个类使用的是浅拷贝，一定要在规范里标记清楚。要不然，就容易用错这个类的拷贝方法，从而导致安全风险。

如果一个类只提供了浅拷贝方法的实现，在使用可变量局部化解决安全隐患时，我们就会遇到很多麻烦。

麻烦的集合

出于效率的考虑，java.util 下的集合类，一般支持的是浅拷贝。比如 ArrayList 的 clone() 方法，执行的就是浅拷贝。如果使用深度拷贝，在很多场景下，集合的低下效率我们难以承受。对于类似于集合这样的类，可变量局部化就不是一个很好的解决方案。

对于集合来说，我们该怎么解决可变量的竞态危害这个问题呢？最主要的办法，就是不要把集合使用在可能产生竞态危害的场景中，我们后面再接着讨论这个问题。

小结

通过对这个案例的讨论，我想和你分享下面三点个人看法。

可变量的传递，存在竞态危害的安全风险；

可变量局部化，是解决可变量竞态危害的一种常用办法；

变量的拷贝，有浅拷贝和深拷贝两种形式；可变量的浅拷贝无法解决竞态危害的威胁。

对于这个案例，你还有什么别的看法吗？

一起来动手

数组是一个常见的难以处理的可变量。和集合一样，数组的拷贝也是有损效率的。什么时候，数组的传递需要拷贝？什么时候不需要拷贝？

不管是 C 语言，Java 还是 JavaScript，数组是一个我们编码经常使用的数据类型。你不妨检查一下你的代码，看看其中的数组使用是否存在我们上面讨论的安全问题。

欢迎你在留言区分享你的发现。

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

35 | 怎么处理敏感信息？

敏感信息，是一个常见的词汇。如果我们接收到了广告信息，骚扰电话，垃圾邮件等，都表明我们个人的敏感信息或多或少地被泄露了。

有些敏感信息的泄露，也许仅仅会使我们感到不便，比如一小部分的垃圾邮件，但有些敏感信息的泄露，会影响我们的消费倾向或者消费决策，损害我们的经济利益，甚至威胁我们的生命安全，比如医疗记录和行程安排的泄露。

在计算能力已经不再奢侈的今天，看似毫不相干的数据甚至都可以推演出非常关键的隐私信息。比如，你每周三次以上在早晨出发的地方，大概率就是你家的位置。只有几十个人知道你家的位置，这是一件正常的事情；可是如果有 10 亿人知道，这就麻烦了，几乎不可能发生的小概率事件随时都有可能光临。

我们一定要有保护敏感信息的意识，不管是我们自身的，还是别人的。在互联网世界里，敏感信息真的是无处不在，你知道有哪些编码技术可以保护敏感信息吗？

什么是敏感信息？

要想保护敏感信息，首先要识别敏感信息。什么是敏感信息呢？ 其实，这个问题本身就是一个特别有意思的话题。你要是查阅关于敏感信息定义的不同文献，就可以体会到不同的立场和不同的利益纠葛。敏感信息的界定范围，也透露了游戏规则制定者对于敏感信息保护的力度和态度。

还记得我们在《如何评估代码的安全缺陷？》这篇文章里提到的，信息安全最基本的三个元素吗？ 私密性、完整性以及可用性是信息安全的三要素。其中，私密性指的是数据未经授权，不得访问，解决的是“谁能看”的问题。在这个框架下，我们可以把敏感信息理解为，未经授权不得泄漏的信息。反过来说，未经授权不得泄漏的信息，都算是敏感信息。

让我们一起来看看最常见的一些敏感信息：

1. 个人敏感信息

个人信息：姓名、性别、年龄、身份证号码、电话号码。

健康信息：健康记录、服药记录、健康状况。

教育记录：教育经历、学习课程、考试分数。

消费记录：所购货物、购买记录、支付记录。

账户信息：信用卡号、社交账号、支付记录。

隐私信息：家庭成员、个人照片、个人行程。

2. 商业敏感信息

商业秘密：设计程序、制作工艺、战略规划、商业模式。

客户信息：客户基本信息、消费记录、订单信息、商业合作和合同。

雇员信息：雇员基本信息、工资报酬。

需要注意的是，上述只是一些常见的、直观的敏感信息。具体到你开发的信息系统，到底什么样的信息是敏感信息，什么样的信息不是敏感信息，还需要进一步分析和界定。

识别出敏感信息之后，就要想办法保护这些信息了。敏感信息的保护，需要恰当的管理，也需要适合的技术。

授权，敏感信息谁能看？

敏感信息指的是未经授权不得泄漏的信息。这个概念可以拆分为三部分：

敏感信息是受保护的信息，未经授权，不得访问；

敏感信息是一段有效信息，有信息处理需求，比如产生、传输、存储、读取、更改、废弃等；

敏感信息是有归属的信息，不同的人有不同的权限。经过授权，合适的人可以执行相应的操作。

是否需要授权是敏感信息和普通信息的最关键差异。不同的人有不同的权限，不同的操作需要不同的权限。该如何处理授权呢？

第一件事情，就是定义权限。只有定义了权限，才能知道如何分配和管理权限。在 JDK 中，权限通过 java.security.Permission 接口来定义。Permission 接口定义权限的名称和操作。比如，java.io.FilePermission 把权限名定义为文件名，把操作定义为：

read

write

execute

delete

readlink

其中，权限的名称就是抽象了的敏感信息；权限的操作就是对信息的处理。如果把权限的名称和权限的操作结合起来，就可以定义特定的权限了。比如，下面的例子就定义了对于文件目录“/home/myhome”的读操作。

permission java.io.FilePermission "/home/myhome", "read";

第二件事情，就是定义权限的主体。也就是说，要明确权限可以分派给谁。在 JDK 中，权限的主体通过 java.security.Principal 接口来定义。Principal 接口可以用来表述个人，组织或者虚拟的账户。比如，com.sun.security.auth.UnixPrincipal 可以用来表述 Unix 的用户。

Principal com.sun.security.auth.UnixPrincipal "duke"

第三件事情，就是要定义权限的归属。也就是说，有了权限的定义和权限主体的定义，我们就可以分配权限了。下面的例子，就是把对“/home/duke”的读写操作权限，赋予给了 Unix 用户 duke。

grant Principal com.sun.security.auth.UnixPrincipal "duke" {

    permission java.io.FilePermission "/home/duke", "read, write";

};

上述的三个例子，就是 Java 权限管理策略文件的最基本概念。更详细的内容，权限管理策略文件的语法以及 API 的调用，请参阅有关 Java 的规范。

敏感信息经过授权才可以使用，这看起来是一个漂亮的解决方案。我们是不是可以高枕无忧了？ 这还远远不够。这套解决方案能够实施下去，还是有很多挑战的。比如说，敏感信息的操作处理过程，也会造成信息的非授权泄漏。

特殊信息，特殊处理

现代信息系统资源，一般都是多用户共享，多应用共享，跨边界合作的，比如内存、硬盘、中央处理器和互联网。而敏感信息是不能共享的，如何在共享的资源内，不留下敏感信息的踪迹？这是一个让人头疼的问题。

比如说吧，要使用敏感信息，就要把敏感信息载入内存，如果发生内存溢出攻击，攻击者就可以绕过权限管理，获取或者修改敏感信息，甚至可以修改对敏感信息的操作。

针对敏感信息的操作，需要特殊的处理和特殊的技术。

敏感信息的无意识泄露是一种比较常见的敏感信息泄露事件。比如说，把敏感信息泄露在抛出异常里，应用日志里，或者序列化对象里。

比如说，如果一个文件不存在，一般的代码实现会倾向于抛出 java.io.FileNotFoundException 异常。为了使异常信息更加直观，我们常常把文件路径包含在异常的消息里，或者记录在应用日志里。

java.io.FileNotFoundException： /home/duke/.ssh was not found

这个异常信息有可能绕过权限管理，传达给未授权用户。这个信息里包含了三个重要的敏感信息：

当前用户名是“duke”；

当前用户没有配置 SSH 协议；

有可能获知特定文件是否存在。

当实现一个文件管理类时，我们可能会习惯于面向对象的机制。比如，给定一个文件的路径，代码就执行一定的读写操作。至于该文件路径是什么，包含什么内容，是否有敏感信息，都不在该类的考虑范围之内。实现这个类时，我们更可能倾向于使用直观友好的异常信息，而不会意识到这些异常信息可能携带敏感数据，导致敏感数据通过异常信息泄露。

这和我们一般的面向对象的编程习惯是不符合的，这就要求我们特别小心。从实现者的角度出发，抛出的异常信息尽量做到不包含可能的敏感信息；从调用者的角度出发，截获的异常信息在传递到上层调用之前，如果有必要，需要做净化处理。比如，把上述的 java.io.FileNotFoundException 转化成更普通的 java.io.IOException。

java.io.IOException： An IOException was caught!

下面的异常堆栈是不是可以接受呢？

java.io.IOException： An IOException was caught!

at com.example.myapp.MyHTTPSerer.myMethod(MyHTTPSerer.java:250)

...

Caused by java.io.FileNotFoundException： /home/duke/.ssh was not found

at com.example.myapp.MyFileStream.open(MyFileStream.java:249)

这个异常堆栈的“Caused by”部分泄露了同样的敏感信息。所以，在做异常信息净化处理时，可能还需要避免传递捕获异常的堆栈。特别是如果调用结果直接面向最终用户，就应当尽量避免使用异常堆栈。比如说，在 HTML 页面中显示异常信息和异常堆栈，就容易出问题。

在后面的文章中，我们还会讨论敏感信息及时归零的话题。这也是对于高度敏感信息的一种特殊处理方式。

小结

通过对这个案例的讨论，我想和你分享下面两点个人看法：

要建立主动保护敏感信息的意识；

要识别系统的敏感信息，并且对敏感信息采取必要的、特殊的处理。

保护敏感信息，是编写安全代码的一个重要内容。下一次，我们接着聊更多关于敏感信息的特殊处理技术。

一起来动手

阅读隐私保护政策，就是一个建立敏感信息保护意识，学习隐私保护策略和技术的一个好办法。 你可以试着阅读Google和腾讯的隐私保护政策。

为了获得相应的服务，作为消费者，我们需要做出什么样的妥协，能得到什么样的保护，我们有什么样的权利？为了提供相应的服务，作为服务者，我们需要什么样的信息，需要多大程度的授权，能够提供什么样的保护？

欢迎你把自己的经验和看法写在留言区，我们一起来学习、思考、精进！

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

36 | 继承有什么安全缺陷？

有时候，为了解决一个问题，我们需要一个解决办法。可是，这个办法本身还会带来更多的问题。新问题的解决带来更新的问题，就这样周而复始，绵延不绝。

比如上一篇文章中，我们说到的敏感信息通过异常信息泄露的问题，就是面向对象设计和实现给我们带来的小困扰。再比如前面还有一个案例，说到了共享内存或者缓存技术带来的潜在危害和挑战，这些都是成熟技术发展背后需要做出的小妥协。只是有时候，这些小小的妥协如果没有被安排好和处理好，可能就会带来不成比例的代价。

评审案例

我们一起来看一段节选的 java.io.FilePermission 类的定义。你知道为什么 FilePermission 被定义为 final 类吗？

package java.io;

// <snipped>

/**

 * This class represents access to a file or directory.  A

 * FilePermission consists of a pathname and a set of actions

 * valid for that pathname.

 * <snipped>

 */

public final class FilePermission

        extends Permission implements Serializable {

    /**

     * Creates a new FilePermission object with the specified actions.

     * <i>path</i> is the pathname of a file or directory, and

     * <i>actions</i> contains a comma-separated list of the desired

     * actions granted on the file or directory. Possible actions are

     * "read", "write", "execute", "delete", and "readlink".

     * <snipped>

     */

    public FilePermission(String path, String actions);

    /**

     * Returns the "canonical string representation" of the actions.

     * That is, this method always returns present actions in the

     * following order: read, write, execute, delete, readlink.

     * <snipped>

     */

    @Override

    public String getActions();

    /**

     * Checks if this FilePermission object "implies" the

     * specified permission.

     * <snipped>

     * @param p the permission to check against.

     *

     * @return <code>true</code> if the specified permission

     *         is not <code>null</code> and is implied by this

     *         object, <code>false</code> otherwise.

     */

    @Override

    public boolean implies(Permission p);

    // <snipped>

}

FilePermission 被声明为 final，也就意味着该类不能被继承，不能被扩展了。我们都知道，在面向对象的设计中，是否具备可扩展性是一个衡量设计优劣的好指标。如果允许扩展的话，那么想要增加一个“link”的操作就会方便很多，只要扩展 FilePermission 类就可以了。 但是对于 FilePermission 这个类，OpenJDK 为什么放弃了可扩展性？

案例分析

如果我们保留 FilePermission 的可扩展性，你来评审一下下面的代码，可以看出这段代码的问题吗？

package com.example;

public final class MyFilePermission extends FilePermission {

    @Override

    public String getActions() {

      return "read";

    }

    @Override

    public boolean implies(Permission p) {

      return true;

    }

}

如果你还没有找出这个问题，可能是因为我还遗漏了对 FilePermission 常见使用场景的介绍。在 Java 的安全管理模式下，一个用户通常可能会被授予有限的权限。 比如用户“xuelei”可以读取用户“duke”的文件，但不能更改用户“duke”的文件。

授权的策咯可能看起来像下面的描述：

grant Principal com.sun.security.auth.UnixPrincipal "xuelei" {

    permission com.example.MyFilePermission "/home/duke", "read";

};

这项策略要想起作用，上面的描述就要转换成一个 MyFilePermission 的实例。然后调用该实例的 implies() 方法类判断是否可以授权一项操作。

Permission myPermission = ...  // read "/home/duke"

public void checkRead() {

  if (myPermission.implies(New FilePermission(file, "read"))) {

    // read is allowed.

  } else {

    // throw exception, read is not allowed.

  }

}

public void checkWrite() {

  if (myPermission.implies(New FilePermission(file, "write"))) {

    // writeis allowed.

  } else {

    // throw exception, write is not allowed.

  }

}

这里请注意，MyFilePermission.implies() 总是返回“true”， 所以上述的 checkRead() 和 checkWrite() 方法总是成功的，不管用户被明确指示授予了什么权限，实际上暗地里他已经被授予了所有权限。这就成功地绕过了 Java 的安全管理。

能够绕过 Java 安全机制的主要原因，在于我们允许了 FilePermission 的扩展。而扩展类的实现，有可能有意或者无意地改变了 FilePermission 的规范和运行，从而带来不可预料的行为。

如果你关注 OpenJDK 安全组的代码评审邮件组，你可能会注意到，对于面向对象的可扩展性这一便利和诱惑，很多工程师能够保持住克制。

保持克制，可能会遗漏一两颗看似近在眼前的甜甜的糖果，但可以减轻你对未来长期的担忧。

一个类或者方法如果使用了 final 关键字，我们可以稍微放宽心。如果没有使用 final 关键字，我们可能需要反复揣摩好长时间，仔细权衡可扩展性可能会带来的弊端。

一个公共类或者方法如果使用了 final 关键字，将来如果需要扩展性，就可以去掉这个关键字。但是，如果最开始没有使用 final 关键字，特别是对于公开的接口来说，将来想要加上就可能是一件非常困难的事。

上面的例子是子类通过改变父类的规范和行为带来的潜在问题。那么父类是不是也可以改变子类的行为呢？ 这听起来有点怪异，但是父类对子类行为的影响，有时候也的确是一个让人非常头疼的问题。

麻烦的继承

我先总结一下，父类对子类行为的影响大致有三种：

改变未继承方法的实现，或者子类调用的方法的实现（super）；

变更父类或者父类方法的规范；

为父类添加新方法。

第一种和第三种相对比较容易理解，第二种稍微复杂一点。我们还是通过一个例子来看看其中的问题。

Hashtable 是一个古老的，被广泛使用的类，它最先出现在 JDK 1.0 中。其中，put() 和 remove() 是两个关键的方法。在 JDK 1.2 中，又有更多的方法被添加进来，比如 entrySet() 方法。

public class Hashtable<K,V> ... {

    // snipped

    /**

     * Returns a {@link Set} view of the mappings contained in

     （ this map.

     * The set is backed by the map, so changes to the map are

     * reflected in the set, and vice-versa.  If the map is modified

     * while an iteration over the set is in progress (except through

     * the iterator's own {@code remove} operation, or through the

     * {@code setValue} operation on a map entry returned by the

     * iterator) the results of the iteration are undefined.  The set

     * supports element removal, which removes the corresponding

     * mapping from the map, via the {@code Iterator.remove},

     * {@code Set.remove}, {@code removeAll}, {@code retainAll} and

     * {@code clear} operations.  It does not support the

     * {@code add} or {@code addAll} operations.

     *

     * @since 1.2

     */

    public Set<Map.Entry<K,V>> entrySet() {

        // snipped

    }

    // snipped

}

这就引入了一个难以察觉的潜在的安全漏洞。 你可能会问，添加一个方法不是很常见吗？这能有什么问题呢？

问题在于继承 Hashtable 的子类。假设有一个子类，它的 Hashtable 里要存放敏感数据，数据的添加和删除都需要授权，在 JDK 1.2 之前，这个子类可以重写 put() 和 remove() 方法，加载权限检查的代码。在 JDK 1.2 中，这个子类可能意识不到 Hashtable 添加了 entrySet() 这个新方法，从而也没有意识到要重写覆盖 entrySet() 方法，然而，通过对 entrySet() 返回值的直接操作，就可以执行数据的添加和删除的操作，成功地绕过了授权。

public class MySensitiveData extends Hashtable<Object, Object> {

    // snipped

    @Override

    public synchronized Object put(Object key, Object value) {

        // check permission and then add the key-value

        // snipped

        super.put(key, value)

    }

    @Override

    public synchronized Object remove(Object key) {

        // check permission and then remove the key-value

        // snipped

        return super.remove(key);

    }

    // snipped, no override of entrySet()

}

MySensitiveData sensitiveData = ...   // get the handle of the data

Set<Map.Entry<Object, Object>> sdSet = sensitiveData.entrySet();

sdSet.remove(...);    // no permission check

sdSet.add(...);       // no permission check

// the sensitive data get modified, unwarranted.

现实中，这种问题非常容易发生。一般来说，我们的代码总是依赖一定的类库，有时候需要扩展某些类。这个类库可能是第三方的产品，也可能是一个独立的内部类库。但遗憾的是，类库并不知道我们需要拓展哪些类，也可能没办法知道我们该如何拓展。

所以，当有一个新方法添加到类库的新版本中时，这个新方法会如何影响扩展类，该类库也没有特别多的想象空间和处理办法。就像 Hashtable 要增加 entrySet() 方法时，让 Hashtable 的维护者意识到有一个特殊的 MySensitiveData 扩展，是非常困难和不现实的。然而 Hashtable 增加 entrySet() 方法，合情又合理，也没有什么值得抱怨的。

然而，当 JDK 1.0/1.1 升级到 JDK 1.2 时，Hashtable 增加了 entrySet() 方法，上述的 MySensitiveData 的实现就存在严重的安全漏洞。要想修复该安全漏洞，MySensitiveData 需要重写覆盖 entrySet() 方法，植入权限检查的代码。

可是，我们怎样可能知道 MySensitiveData 需要修改呢！ 一般来说，如果依赖的类库进行了升级，没有影响应用的正常运营，我们就正常升级了，而不会想到检查依赖类库做了哪些具体的变更，以及评估每个变更潜在的影响。这实在不是软件升级的初衷，也远远超越了大部分组织的能力范围。

而且，如果 MySensitiveData 不是直接继承 Hashtable，而是经过了中间环节，这个问题就会更加隐晦，更加难以察觉。

public class IntermediateOne extends Hashtable<Object, Object>;

public class IntermediateTwo extends IntermediateOne;

public class Intermediate extends IntermediateTwo;

public class MySensitiveData extends Intermediate;

糟糕的是，随着语言变得越来越高级，类库越来越丰富，发现这些潜在问题的难度也是节节攀升。我几乎已经不期待肉眼可以发现并防范这类问题了。

那么，到底有没有办法可以防范此类风险呢？

主要有两个方法。

一方面，当我们变更一个可扩展类时，要极其谨慎小心。一个类如果可以不变更，就尽量不要变更，能在现有框架下解决问题，就尽量不要试图创造新的轮子。有时候，我们的确难以压制想要创造出什么好东西的冲动，这是非常好的品质。只是变更公开类库时，一定要多考虑这么做的潜在影响。你是不是开始思念 final 关键字的好处了？

另一方面，当我们扩展一个类时，如果涉及到敏感信息的授权与保护，可以考虑使用代理的模式，而不是继承的模式。代理模式可以有效地降低可扩展对象的新增方法带来的影响。

public class MySensitiveData {

    private final Hashtable hashtable = ...

    public synchronized Object put(Object key, Object value) {

        // check permission and then add the key-value

        hashtable.put(key, value)

    }

    public synchronized Object remove(Object key) {

        // check permission and then remove the key-value

        return hashtable.remove(key);

    }

}

我们使用了 Java 语言来讨论继承的问题，其实这是一个面向对象机制的普遍的问题，甚至它也不单单是面向对象语言的问题，比如使用 C 语言的设计和实现，也存在类似的问题。

小结

通过对这个案例的讨论，我想和你分享下面两点个人看法。

一个可扩展的类，子类和父类可能会相互影响，从而导致不可预知的行为。

涉及敏感信息的类，增加可扩展性不一定是个优先选项，要尽量避免父类或者子类的影响。

学会处理和保护敏感信息，是一个优秀工程师必须迈过的门槛。

一起来动手

了解语言和各种固定模式的缺陷，是我们打怪升级的一个很好的办法。有时候，我们偏重于学习语言或者设计经验的优点，忽视了它们背后做出小小的妥协，或者缺陷。如果能利用好优点，处理好缺陷，我们就可以更好地掌握这些经验总结。毕竟世上哪有什么完美的东西呢？不完美的东西，用好了，就是好东西。

我们利用讨论区，来聊聊设计模式这个老掉牙的、备受争议的话题。说起“老掉牙”，科技的进步真是快，设计模式十多年前还是一个时髦的话题，如今已经不太受待见了，虽然我们或多或少，或直接或间接地都受益于设计模式的思想。如果你了解过设计模式，你能够分享某个设计模式的优点和缺陷吗？ 使用设计模式有没有给你带来实际的困扰呢？

上面的例子中，我们提到了使用代理模式来降低父类对子类的影响。那么你知道代理模式的缺陷吗？

欢迎你把自己的经验和看法写在留言区，我们一起来学习、思考、精进！

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

37 | 边界，信任的分水岭

边界是信息安全里一个重要的概念。如果不能清晰地界定信任的边界，并且有效地守护好这个边界，那么编写安全的代码几乎就是一项不可能完成的任务。

评审案例

计算机之间的通信，尤其是建立在非可靠连接之上的通信，如果我们能够知道对方是否处于活跃状态，会大幅度地提升通信效率。在传输层安全通信的场景下，这种检测对方活跃状态的协议，叫做心跳协议。

心跳协议的基本原理，就是发起方给对方发送一段检测数据，如果对方能原封不动地把检测数据都送回，就证明对方处于活跃状态。

下面的数据结构，定义的就是包含检测数据的通信消息。

   struct {

      HeartbeatMessageType type;

      uint16 payload_length;

      opaque payload[HeartbeatMessage.payload_length];

      opaque padding[padding_length];

   } HeartbeatMessage;

其中，type 是一个字节，表明心跳检测的类型；payload_length 使用两个字节，定义的是检测数据的长度；payload 的字节数由 payload_length 确定，它携带的是检测数据；padding 是随机的填充数据，最少 16 个字节。

如果愿意回应心跳请求，接收方就拷贝检测数据（payload_length 和 payload），并把它封装在同样的数据结构里。

下面的这段代码（函数 process_heartbeat，为便于阅读，在源代码基础上有修改），就是接收方处理心跳请求的 C 语言代码。你能看出其中的问题吗？

int process_heartbeat(

        unsigned char* request, unsigned int request_length) {

	unsigned char *p = request, *pl;

	unsigned short hbtype;

	unsigned int payload_length;

	unsigned int padding_length = 16; /* Use minimum padding */

	/* Read type and payload length first */

	hbtype = *p++;

	payload_length = ((unsigned int)(*p++)) << 8L |

	                 ((unsigned int)(*p++));

	pl = p;

    // produce response heaetbeat message

	unsigned char *response, *bp;

	/* Allocate memory for the response, size is 1 bytes

	 * message type, plus 2 bytes payload length, plus

	 * payload, plus padding

	 */

	response = malloc(1 + 2 + payload_length + padding_length);

	bp = response;

	/* Enter response type, length and copy payload */

	*bp++ = 1;  /* 1: response heartbeat type */

	*bp++ = (unsigned char)((payload_length >> 8L) & 0xff);

	*bp++ = (unsigned char)((payload_length      ) & 0xff);

	memcpy(bp, pl, payload_length);

	bp += payload_length;

    // snipped

	return 0;

}

上面这段代码，读取了请求的 payload_length 字段，然后按照 payload_length 的大小，分配了一段内存。然后，从请求数据的 payload 指针开始，拷贝了和 payload_length 一样大小的一段数据。这段数据，就是要回应给请求方的检测数据。 按照协议，这段数据应该和请求信息的检测数据一模一样。

比如说吧，如果心跳请求的数据是：

type:           0x01

payload_length: 0x00, 0x05                       // 5

payload:        {0x68, 0x65, 0x6c, 0x6c, 0x6f};  // 'hello'

padding:        {0xCF, 0xED, ...};

按照协议和上面实现的代码，心跳请求的回应数据应该是：

type:           0x01

payload_length: 0x00, 0x05                       // 5

payload:        {0x68, 0x65, 0x6c, 0x6c, 0x6f};  // 'hello'

padding:        {0x07, 0x91, ...};

这看起来很美好，是吧？ 可是，如果请求方心有图谋，在心跳请求数据上动了手脚，问题就来了。比如说吧，还是类似的心跳请求，但是 payload_length 的大小和真实的 payload 大小不相符合。下面的这段请求数据，检测数据还是只有 5 个字节，但是 payload_length 字段使用了一个大于 5 的数字。

type:           0x01

payload_length: 0x04, 0x00                       // 1024

payload:        {0x68, 0x65, 0x6c, 0x6c, 0x6f};  // hello

padding:        {0xCF, 0xED, ...};

按照协议的本意，这不是一个合法的心跳请求。上面处理心跳请求的代码，不能识别出这是一个不合法的请求，依旧完成了心跳请求的回应。

type:           0x01

payload_length: 0x04, 0x00                       // 1024

payload:        {0x68, 0x65, 0x6c, 0x6c, 0x6f,   // 'hello

                 0xCF, 0xED, ...                 // request padding

                 0x70, 0x72, 0x69, 0x76, 0x69, 0x76, 0x61, 0x74,

                 0x65, 0x20, 0x6b, 0x65, 0x79, 0x20,

                  ... };  // private key "..."

padding:        {0x07, 0x91, ...};

心跳请求的真实检测数据只有 5 个字节，返回检测数据有 1024 个字节，这中间有 1019 个字节的差距。这 1019 个字节从哪儿来呢？由于代码使用了 memcpy() 函数，这 1019 个字节就是从 payload 指针（pl）后面的内存中被读取出来的。这些内存中可能包含很多敏感信息，比如密码的私钥，用户的社会保障号等等。

这就是著名的心脏滴血漏洞（Heartbleed），这个漏洞出现在 OpenSSL 的代码里。2014 年 4 月 7 日，OpenSSL 发布了这个漏洞的修复版。由于 OpenSSL 的广泛使用，有大批的产品和服务需要升级到修复版，而升级需要时间。修复版刚刚发布，像猎食者一样的黑客抢在产品和服务的升级完成之前，马上就展开了攻击。赛跑立即展开！仅隔一天，2014 年 4 月 8 日，加拿大税务局遭受了长达 6 个小时的攻击，大约有 900 人的社会保障号被泄漏。2014 年 4 月 14 日，英国育儿网站 Mumsnet 有几个用户帐户被劫持，其中包括了其首席执行官的账户。2014 年 8 月，一家世界 500 强医疗服务机构透露，心脏滴血漏洞公开一周后，他们的系统遭受攻击，导致四百五十万条医疗数据被泄漏。

【图片来自http://heartbleed.com/， https://en.wikipedia.org/wiki/Heartbleed#/media/File:Heartbleed.svg】

案例分析

没有检查和拒绝不合法的请求，是心脏滴血漏洞出现的根本原因。这个漏洞的修复也很简单，增加检查心跳请求的数据结构是否合法的代码就行了。

下面的代码就是修复后的版本。修复后的代码，加入了对心跳请求 payload_length 的检查。

int process_heartbeat(

        unsigned char* request, unsigned int request_length) {

	unsigned char *p = request, *pl;

	unsigned short hbtype;

	unsigned int payload_length;

	unsigned int padding_length = 16; /* Use minimum padding */

	/* Read type and payload length first */

	if (1 + 2 + 16 > request_length) {

        /* silently discard */

		return 0;

	}

	hbtype = *p++;

	payload_length = ((unsigned int)(*p++)) << 8L |

	                 ((unsigned int)(*p++));

	if (1 + 2 + payload_length + 16 > request_length) {

		/* silently discard */

		return 0;

	}

	pl = p;

    // produce response heaetbeat message

	unsigned char *response, *bp;

	/* Allocate memory for the response, size is 1 bytes

	 * message type, plus 2 bytes payload length, plus

	 * payload, plus padding

	 */

	response = malloc(1 + 2 + payload_length + padding_length);

	bp = response;

	/* Enter response type, length and copy payload */

	*bp++ = 1;  /* 1: response heartbeat type */

	*bp++ = (unsigned char)((payload_length >> 8L) & 0xff);

	*bp++ = (unsigned char)((payload_length      ) & 0xff);

	memcpy(bp, pl, payload_length);

	bp += payload_length;

    // snipped

    return 0;

}

如果比较下 process_heartbeat() 函数修复前后的实现代码，我们就会发现修复前的危险性主要来自于两点：

没有检查外部数据的合法性（payload_length 和 payload）；

内存的分配和拷贝依赖于外部的未校验数据（malloc 和 memcpy）。

这两点都违反了一条基本的安全编码原则，我们在前面提到过这条原则，那就是：跨界的数据不可信任。

信任的边界

不知道你有没有这样的疑问：类似于 memcpy() 函数，如果 process_heartbeat() 函数的传入参数 request_length 的数值，大于传入参数 request 实际拥有的数据量，这个函数不是还有内存泄漏问题吗？

如果独立地看上面的代码，这样的问题是有可能存在的。但是，process_heartbeat() 是 OpenSSL 的一个内部函数，它的调用代码，已经检查过 request 容量和 request_length 的匹配问题。所以，在 process_heartbeat() 的实现代码里，我们就不再操心这个匹配的问题了。

对一个函数来说，到底哪些传入参数应该检查，哪些传入参数不需要检查？这的确是一个让人头疼的问题。

一般来说，对于代码内部产生的数据，我们可以信任它们的合法性；而对于外部传入的数据，就不能信任它们的合法性了。外部数据，需要先检验，再使用。

区分内部数据、外部数据的依据，就是数据的最原始来源，而不是数据在代码中的位置。

比如下面的示意图，标明的就是一些典型的数据检查点。 其中小写字母代表数据，大写字母标示的方框代表函数或者方法，数字代表检查点，箭头代表数据流向。

数据 a 是一个外部输入数据，函数 A 使用数据 a 之前，需要校验它的合法性（检查点 1）。

数据 b 是一个外部输入数据，函数 A 使用数据 b 之前，完全校验了它的合法性（检查点 2）。函数 A 内部调用的函数 B 在使用数据 b 时，就不再需要检查它的合法性了。

数据 c 是一个外部输入数据，函数 A 使用数据 c 之前，部分校验了它的合法性（检查点 3）。函数 A 只能使用校验了合法性的部分数据。函数 A 内部调用的函数 B 在使用数据 c 时，如果需要使用未被检验部分的数据，还要检查它的未被校验部分的合法性（检查点 4）。

数据 d 是一个外部输入数据，函数 A 使用数据 d 之前，部分校验了它的合法性（检查点 5）。函数 A 内部调用的函数 B，没有使用该数据，但是把该数据传送给了函数 C。函数 C 在使用数据 d 时，如果需要使用未被检验部分的数据，还要检查它的未被校验部分的合法性（检查点 6）。

数据 e 和 f 是一个内部数据，函数 C 使用内部数据时，不需要校验它的合法性。

数据 g 是一个内部数据，由函数 A 产生，并且输出到外部。这时候，不需要检验数据 g 的合法性，但是需要防护输出数据的变化对内部函数 A 状态的影响（防护点 7）。

原则上，对于外部输入数据的合法性，我们要尽早校验，尽量全面校验。但是有时候，只有把数据分解到一定程度之后，我们才有可能完成对数据的全面校验，这时候就比较容易造成数据校验遗漏。

我们上面讨论过的心脏滴血漏洞，就有点像数据 d 的用例，调用关系多了几层，数据校验的遗漏就难以察觉了。

哪些是外部数据？

你是不是还有一个疑问：为什数据 e 和 f 对函数 C 来说，就不算是外部数据了？它们明明是函数 C 的外部输入数据呀！

当我们说跨界的数据时，这些数据指的是一个系统边界外部产生的数据。如果我们把函数 A、函数 B 和函数 C 看成一个系统，那么数据 e 和数据 f 就是这个系统边界内部产生的数据。内部产生的数据，一般是合法的，要不然就存在代码的逻辑错误；内部产生的数据，一般也是安全的，不会故意嵌入攻击性逻辑。所以，为了编码和运行的效率，我们一般会选择信任内部产生的数据。

一般的编码环境下，我们需要考量四类外部数据：

用户输入数据（配置信息、命令行输入，用户界面输入等）；

I/O 输入数据（TCP/UDP 连接，文件 I/O）；

公开接口输入数据；

公开接口输出数据。

我想，前三类外部数据都容易理解。第四类公开接口输出数据，不是内部数据吗？怎么变成需要考量的外部数据了？我们在前面的章节讨论过这个问题。

公开接口的输出数据，其实是把内部数据外部化了。如果输出数据是共享的可变量（比如没有深拷贝的集合和数组），那么外部的代码就可以通过修改输出数据，进而影响原接口的行为。这也算是一种意料之外的“输入”。

需要注意的是，公开接口的规范，要标明可变量的处理方式。要不然，调用者就不清楚可不可以修改可变量。

让调用者猜测公开接口的行为，会埋下兼容性的祸根。

比如下面的例子，就是两个 Java 核心类库的公开方法。这两个方法，对于传入、传出的可变量（数组）都做了拷贝，并且在接口规范里声明了变量拷贝。

package javax.net.ssl;

// snipped

public class SSLParameters {

    private String[] applicationProtocols = new String[0];

    // snipped

    /**

     * Returns a prioritized array of application-layer protocol names

     * that can be negotiated over the SSL/TLS/DTLS protocols.

     * <snipped>

     * This method will return a new array each time it is invoked.

     *

     * @return a non-null, possibly zero-length array of application

     *         protocol {@code String}s.  The array is ordered based

     *         on protocol preference, with {@code protocols[0]}

     *         being the most preferred.

     * @see #setApplicationProtocols

     * @since 9

     */

    public String[] getApplicationProtocols() {

        return applicationProtocols.clone();

    }

    /**

     * Sets the prioritized array of application-layer protocol names

     * that can be negotiated over the SSL/TLS/DTLS protocols.

     * <snipped>

     * @implSpec

     * This method will make a copy of the {@code protocols} array.

     * <snipped>

     * @see #getApplicationProtocols

     * @since 9

     */

    public void setApplicationProtocols(String[] protocols) {

        if (protocols == null) {

            throw new IllegalArgumentException("protocols was null");

        }

        String[] tempProtocols = protocols.clone();

        for (String p : tempProtocols) {

            if (p == null || p.isEmpty()) {

                throw new IllegalArgumentException(

                    "An element of protocols was null/empty");

            }

        }

        applicationProtocols = tempProtocols;

    }

}

从上面的例子中，我们也可以体会到，公开接口的编码要比内部接口的编码复杂得多。因为我们无法预料接口的使用者会怎么创造性地使用这些接口。公开接口的实现一般要慎重地考虑安全防护措施，这让公开接口的设计、规范和实现都变得很复杂。从这个意义上来说，我们也需要遵守在第二部分“经济的代码”里谈到的原则：接口要简单直观。

小结

通过对这个案例的讨论，我想和你分享下面两点个人看法。

外部输入数据，需要检查数据的合法性；

公开接口的输入和输出数据，还要考虑可变量的传递带来的危害。

一起来动手

外部数据的合法性问题，是信息安全里的一大类问题，也是安全攻击者经常利用的一类安全漏洞。

区分内部数据、外部数据的依据，是数据的最原始来源，而不是数据在代码中的位置。这一点让外部数据的识别变得有点艰难，特别是代码层数比较多的时候，我们可能没有办法识别一个传入参数，到底是内部数据还是外部数据。在这种情况下，我们需要采取比较保守的姿态，无法识别来源的数据，不应该是可信任的数据。

这一次的练习题，我们按照保守的姿态，来分析下面这段代码中的数据可信任性问题。

import java.util.HashMap;

import java.util.Map;

public class Solution {

    /**

     * Given an array of integers, return indices of the two numbers

     * such that they add up to a specific target.

     */

    public int[] twoSum(int[] nums, int target) {

        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {

            int complement = target - nums[i];

            if (map.containsKey(complement)) {

                return new int[] { map.get(complement), i };

            }

            map.put(nums[i], i);

        }

        throw new IllegalArgumentException("No two sum solution");

    }

}

欢迎你把你的看法写在留言区，我们一起来学习、思考、精进！

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

38 | 对象序列化的危害有多大？

如果一个函数或者对象，不管它位于多么遥远的地方，都可以在本地直接被调用，那该有多好呀！这是一个非常朴素、美好的想法。基于这个设想，诞生了很多伟大的技术和协议，比如远程过程调用（RPC）、远程方法调用（RMI）、分布式对象（Distributed Object）、组件对象模型（COM）、公共对象请求代理（CORBA）和简单对象访问协议（SOAP）等……这个列表还可以很长很长。

躲在这些协议背后的核心技术之一，就是序列化。简单地说，序列化就是要把一个使用场景中的一个函数或者对象以及它们的执行环境，打包成一段可以传输的数据，然后把该数据传输给另外一个使用场景。在这个使用场景中，该数据被拆解成适当的函数或者对象，包括该函数或者对象的执行环境。这样，该函数或者对象就可以在不同的场景下使用了。

数据拆解的过程，就是反序列化。打包、传输、拆解是序列化技术的三个关键步骤。由于传输的是数据，打包和拆解可能使用不同的编程语言，运行在不同的操作系统上。这样就带来了跨平台和跨语言的好处。而数据能够传输，就意味着可以带来分布式的好处。数据当然也可以存储，而可以存储意味着相关对象的生命周期的延长，这是不是也是一个非常值得兴奋的特点？

的确是一个美妙的想法，对吧？ 如果一个想法不是足够好，它也不会造成足够坏的影响。

评审案例

我们用 Java 语言的例子来看看序列化的问题。先一起来看一段节选的 Java 代码。你能看出这段代码有什么问题吗？该怎么解决这个问题？

public class Person implements Serializable {

    // <snipped>

    private String firstName;

    private String lastName;

    private String birthday;

    private String socialSecurityNumber;

    public Person(String firstName, String lastName,

            String birthday, String socialSecurityNumber) {

        this.firstName = firstName;

        this.lastName = lastName;

        this.birthday = birthday;

        this.socialSecurityNumber = socialSecurityNumber;

    }

    // <snipped>

}

注意，socialSecurityNumber 表示社会保障号，是一个高度敏感、需要高度安全保护的数据。如果社会保障号以及姓名、生日等信息被泄露，那么冒名顶替者就可以用这个号码举债买房、买车，而真实用户则要背负相关的债务。一旦社会保障号被泄露，想要证明并不是你申请了贷款，远远不是一件轻而易举的事情。在有些国家，社会保障号的保护本身甚至都是一个不小的生意。 在一个信息系统中，除了本人以及授权用户，任何其他人都不应该获知社会保障号以及相关的个人信息。

上述的代码，存在泄露社会保障号以及相关的个人信息的巨大风险。

案例分析

打包、传输、拆解是序列化技术的三个关键步骤。我们来分别看看这三个步骤。

首先，打包环节会把一个 Person 实例里的姓名、生日、社会保障号等信息转化为二进制数据。这段数据可以被传输、存储和拆解。任何人看到这段二进制数据，都可以拆解，还原成一个 Person 实例，从而获得个人敏感信息。这段二进制数据在传输和存储的过程中，有可能被恶意的攻击者修改，从而影响 Person 实例的还原。如果这个实例涉及到具体的商业交易，那么通过这样的攻击，还可以修改交易对象。

你看，序列化后的每一个环节，都有可能遭受潜在的攻击。序列化的问题有多严重呢？据说，大约有一半的 Java 漏洞和序列化技术有直接或者间接的关系。而且，由于序列化可以使用的场景非常多，序列化对象既可以看又可以改，这样就导致序列化安全漏洞的等级往往非常高，影响非常大。甚至每年都会有公司专门收集、整理和分析序列化漏洞，这就加剧了序列化安全漏洞的影响，特别是对于那些没有及时修复的系统来说。

1997 年，Java 引入序列化技术，至今二十多年里，由于序列化技术本身的安全问题，Java 尝尽了其中的酸楚。这是一个“美妙”的想法带来的可怕错误。如果有一天，Java 废弃了序列化技术，那一点儿也不值得惊讶。毕竟，和得到的好处相比，要付出的代价实在是太沉重了！

如果你的应用还没有开始使用序列化技术，这很好，不要惦记序列化的好处，坚持不要使用序列化。如果你的应用已经使用了序列化技术，那么可以做些什么来防范或者降低序列化的风险呢？

额外的防护

序列化技术本身并没有内在的安全防护措施，这也是 Java 序列化为什么会这么令人诅丧的原因之一。如果一定要使用序列化技术，我们就需要设计、部署、加固序列化的安全防线。

我们先聊聊面对序列化带来的种种问题，该如何保护被序列化的敏感数据。

首先推荐的方式是，含有敏感数据的类，不要支持序列化。当然，这也就主动放弃了序列化带来的好处。

次优的方式是，不要序列化敏感数据，把敏感数据排除在序列化数据之外。比如，案例中的序列化数据可以抽象地表述为如下的四项：

  firstName | lastName | birthday | socialSecurityNumber

能不能把敏感的 socialSecurityNumber 和 birthday 排除在外呢？Java 语言的关键字 transient 就是为这一功能设计的。

public class Person implements Serializable {

    // <snipped>

    private String firstName;

    private String lastName;

    private transient String birthday;              // sensitive data

    private transient String socialSecurityNumber;  // sensitive data

    public Person(String firstName, String lastName,

            String birthday, String socialSecurityNumber) {

        this.firstName = firstName;

        this.lastName = lastName;

        this.birthday = birthday;

        this.socialSecurityNumber = socialSecurityNumber;

    }

    // <snipped>

}

如果把 socialSecurityNumber 和 birthday 变量声明为 transient，对象实例的序列化就会把这两个变量排除在外。这个时候，序列化数据就不包含敏感数据了。

  firstName | lastName

排除敏感数据的序列化，还有另一种办法，那就是指定可以序列化的非敏感数据。如果把 transient 关键字提供的变量声明看成一个黑名单模式，Java 还提供了一个白名单模式。使用静态的 serialPersistentFields 变量，可以指定哪些变量可以序列化。上面的案例中，如果只序列化 firstName 和 lastName 变量，那么敏感的 socialSecurityNumber 和 birthday 变量自然就被排除在外了。

public class Person implements Serializable {

    // <snipped>

    private String firstName;

    private String lastName;

    private String birthday;              // sensitive data

    private String socialSecurityNumber;  // sensitive data

    // list of serializable fields

    private static final ObjectStreamField[]

        serialPersistentFields = {

            new ObjectStreamField("firstName", Person.class),

            new ObjectStreamField("lastName", Person.class)

        };

    // <snipped>

}

可是，如果把敏感数据排除在序列化数据之外，也就意味着敏感数据不会在拆解后的对象实例中出现。这就使得序列化之前的实例和反序列化之后的实例并不一致。这种差异的存在，就足以使得序列化名存实亡，反序列化后的对象实例可能就没有太多的实际意义了。

那么有没有一种方法，既可以保护敏感数据，也能保持对象实例序列化前后的等价呢？办法还是有的。

如果在一个完全可信任的环境下，既不用担心敏感信息的泄露，也不用担心敏感信息的修改，更不用担心对象会被用于非可信的环境，敏感数据可以正常实例化了。然而，这严重限制了对象的使用环境，如果用错了环境，就会面临严肃的安全问题。

如果对象有可能适用于非可信的环境，就要使用复杂一些的技术。比如使用加密和签名技术，解决“谁能看”和“谁能改”的安全问题。可是，复杂技术的使用，几乎意味着我们对性能要求做出了妥协。面对这样的妥协，是否还需要使用序列化，有时候也是一个两难的选择。

小结

通过对这个评审案例的讨论，我想和你分享下面两点个人看法。

序列化技术不是一个有安全保障的技术，序列化数据的传输和拆解过程都可能被攻击者利用；

要尽量避免敏感信息的序列化。

除了上述我们说到的方法，敏感信息在序列化过程中的处理和保护，还有三种常见的方法：

实现 writeObject，主动地、有选择地序列化指定数据。writeObject 和 serialPersistentFields 变量都是指定序列化数据，但区别在于 writeObject() 覆盖了序列化的缺省函数，所以编码可以更自由；

实现 writeReplace， 使用序列化代理；

实现 Externalizable 接口。

我们把这三种方法的使用，留给讨论区，欢迎你对这三种方法做总结、分析，并与我一起交流。

一起来动手

下面的这段 Java 代码，有一个隐藏的序列化安全问题。你能找到这个问题，并且解决掉这个问题吗？

public class Person extends HashMap<String, String> {

    // <snipped>

    public Person(String firstName, String lastName,

            String birthday, String socialSecurityNumber) {

        super();

        super.put("firstName", firstName);

        super.put("lastName", lastName);

        super.put("birthday", birthday);

        super.put("socialSecurityNumber", socialSecurityNumber);

    }

    // <snipped>

}

欢迎你把自己看到的问题和想到的解决方案写在留言区，我们一起来学习、思考、精进！

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

39 | 怎么控制好代码的权力？

在前面，我们讨论了“敏感信息经过授权才可以使用”的这样一条有关编码安全的实践。我们还可以把这个实践扩展到更大的范围：信息和资源，需经授权，方可使用。这个信息和资源，不仅仅包括用户数据这样的敏感信息，还包括计算机代码、产品和服务。

授权使用这些资源，需要遵循“最小授权”的原则。所授予的权力，能够让应用程序完成对应的任务就行，不要授予多余的权力。为了方便，我们可以把“最小授权”这个概念拆分成如下的两个部分来理解：

最小权力的设计

最小限度的授予

最小权力的设计

其实，不管使用什么编程语言，我们编写的代码都会涉及到代码权力的设计。最常见的设计，就是代码的访问控制权限的设计。

一段代码访问应用程序接口的过程，一般需要至少两个步骤，第一步是加载类库，第二步是调用接口。这两个步骤，都需要设计好访问控制权限。

模块的访问权限

下面的例子，就是一个 Java 模块的权限设计（module-info.java）。这个权限设计，定义了一个 example.coding 模块。这个模块允许外部代码使用它提供的 com.example.coding 内部接口。

module example.coding {

    exports com.example.coding;

}

这个模块可能还包含其他的接口，比如位于 com.example.implement 包内的代码。由于模块的定义没有允许外部代码使用除了 com.example.coding 包空间以外的接口，那么 com.example.implement 包内的接口，即便是 public 接口，外部代码也不能直接访问了。

这个模块被加载时，它可以接受的访问控制权限也就相应地确定了。

我们在设计一个模块时，需要尽量把命名空间设计好，开放的接口放在一个开放的包里；内部接口或者代码实现，放在封闭的包里。把开放的部分和封闭的部分，分割开来。这样我们就设计了一道安全的边界，开放包里的代码，经过精心设计和耐心打磨，处理好潜在的安全问题。而封闭包里的代码编写就少了很多安全的顾虑，可以让编写更有效率。

这样的设计，也使得这个模块和外部的接触面更小。接触面越小，代码的安全问题就越少，代码的接口就越容易管理。

模块化是 JDK 9 引入的一个新特性。

在 JDK 9 之前，有很多声明为 public 的内部类，比如 com.sun.net.internal 包里的类。虽然这些内部的类声明为 public，但是它们的真实意图往往是方便内部不同包内的接口共享，而不是开放给外部的应用程序使用。所以，Java 的文档会一再强调，应用程序不要使用内部类，即使这些类声明为 public。因为这些内部类可能随时被改变，随时被删除。另外，内部类一般也没有规范的文档，实现的代码依赖内部假设，使用场景严格受限，这也让这些类的使用充满了陷阱。

然而，这些内部的 public 类毕竟有它们的价值和便利的地方，一些应用为了方便，使用了内部类。这不仅给内部类的修改带来了很大的困扰，也让应用程序面临不安定的兼容性和安全性问题。

Java 的模块化这个特性，通过增加一个访问控制边界，更好地区分开了开放和封闭的空间，提高了代码的安全性和可维护性。

接口的访问权限

Java 接口的访问控制权限，是由我们熟知的三个修饰符来定义的。这三个修饰符就是 public、 protected 和 private。如果三个修饰符都不使用，那就是缺省的访问控制权限。如果加上缺省的权限，那么 Java 的访问控制权限可以分为四类。

这四类权限定义接口的访问控制，具体可以参考下面的表格。

掌握这四类权限，是 Java 编码的基本功，我们都很熟悉，这里我们强调的，是它们的使用优先级。

在我们日常的编码中，需要遵循“优先最小权限”的原则。也就是说，应该优先使用权限最小的方案。按照这样的原则，Java 接口的访问控制权限的使用优先级，从高到低的顺序是：

private

缺省的权限

protected

public

这需要我们养成一个习惯，遇到不是 private 的接口，我们一定要想一想：这个接口可以改成 private 接口吗？如果不能，接口需要的最小访问控制权限是什么？我们还可以做些什么事情，来降低这个接口的权限，减小接口的开放程度？

掌握 Java 接口的访问控制权限虽然是 Java 编码基本功之一，但要真的用好，落实到设计和编码上，也不是一件容易的事情。由于在编码过程中，我们往往会集中精力在代码的业务逻辑上，忽视了代码权限控制的概念。在 OpenJDK 的代码评审中，经常可以看到访问控制权限使用的疏忽。即使是对于资深的工程师而言，这也是一个常见的编码疏漏。

Java 接口的访问控制权限，是我们可以设置、使用的另外一道安全边界。这道边界，把类、包、子类以及外部代码区隔开来。越开放的权限越需要控制，越封闭的权限越容易维护。

修改的权限

还有一类权限，不太容易引起我们的注意。它就是修改的权限。在编程语言语法层面，Java 语言中，这个权限由 final 修饰符来定义，而 C 语言使用 const 关键字。

final 的类和方法，不允许被继承，阻断了代码实现的修改；final 的变量，不允许被修改，阻断了使用者带来的变更。我们前面讨论过可变量的威胁和继承的危害，限制修改权限，是规避这两类陷阱的最有效办法。

final 类：

private final class Foo {

    // snipped

}

final 方法：

private class Foo {

    // snipped

    final InputStream getInputStream() {

        // snipped

    }

}

final 变量：

private final class Foo {

    private final Socket socket;

    // snipped

}

同样的，编码的时候，我们也要养成限制修改权限的习惯：能使用 final 修饰符的地方，就使用 final 修饰符；没有使用 final 修饰符的地方，可以想一想使用 final 修饰符能不能带来代码的改进；不能使用 final 修饰符的地方，想一想有没有可变量和继承的陷阱，如果存在这样的陷阱，就要考虑需不需要规避这些陷阱，以及该怎么规避这些陷阱。比如在前面的章节里，我们讨论了可以使用代理模式，当然还有其他的方法。

最小限度的授予

权力这东西，少了处处掣肘，多了飞扬跋扈，是一个很难平衡、很难设计的东西。一个操作系统，设计有只手遮天的 root 用户；一门编程语言，设计有无所不能的 AllPermission 和特权代码。

这些方式看似可以带来美好的绝对的权力，却恰恰是攻击者喜欢的命门。只要能够获得这绝对的权力，攻击者就可以为所欲为，轻而易举地跨过所有安全防线。只手遮天的权力，从来都是双刃剑！

我们前面讲过权限的三个要素：权限、权限的主体和权限的归属。

grant Principal com.sun.security.auth.UnixPrincipal "duke" {

    permission java.io.FilePermission "/home/duke", "read, write";

};

要把这三个要素使用好，当然需要花费时间设计好这三个要素并且做好权限的分配。这多多少少有一点点麻烦。于是，就有人使用了无所不能的 AllPermission。

比如下面例子中的授权策略，就授予了 my.dirs 目录下的所有类库所有的权限。

grant codeBase "file:${my.dirs}}/*" {

    permission java.security.AllPermission;

};

这样的授权策略看着真是痛快、简单。其实，它的复杂性和由此带来的痛苦像是一座隐藏在水面下的冰山。

这个授权要想做到安全，至少需要做到两点。第一点就是 my.dirs 目录受到严格的保护，不能放入不被信任的代码。第二点就是 my.dirs 目录下的代码，没有安全漏洞可以泄漏这无所不能的权限。

要想做到第一点，技术本身已经不足以保证，还需要组织管理和规章制度的介入。但是管理和制度的介入，除了让系统维护人员更痛苦之外，还会让安全保障的强度大打折扣。

第二点提到的问题本身就是一个悖论，即使我们有良好的愿望以及强大的实力，也做不到代码没有安全漏洞。所以实际上，这只能是一个永远都不可企及的美好梦想而已。

安全策略的设计和实现，是一个很专业的技术。如果代码有需要，我们需要花点时间学好、用好这样的技术。

限制特权代码

类似于操作系统的 root 用户和安全策略的 AllPermission，还有一种获取绝对权力的方式，那就是使用特权代码。Java 中，特权代码的调用接口是 AccessController.doPrivileged​() 方法。

AccessController.doPrivileged​() 获取特权的方法有两种。第一种形式，是使用调用者的权力。如果调用者是一个绝对权力拥有者，这个方法就拥有绝对的权力。

public static <T> T doPrivileged​(PrivilegedAction<T> action)；

第二种形式，是在调用者权力许可的范围内，使用指定的权力。这种形式大幅度缩小了特权代码的权限范围，减轻了安全攻击的风险。

public static <T> T doPrivileged​(PrivilegedAction<T> action,

                                 AccessControlContext context,

                                 Permission... perms)；

如果你的代码需要使用特权代码，我建议优先考虑使用指定权力的接口。这会让你的代码避免一定的安全风险。

特权代码要短小

安全策略的设计和实现，以及特权代码的使用，都是很专业的内容。一般而言，我们应该优先考虑编写和使用无特权要求的代码，这样可以尽量规避掉一些不必要的安全风险和复杂性。

如果不能够避免特权代码的使用，那么特权代码的尺寸一定要短小，只使用它处理需要特权的流程，尽量别在特权代码里处理一般的用户数据和业务。

AccessController.doPrivileged((PrivilegedAction<Void>)

    () -> {

        // Privileged code goes here.

        // The code should be short and simple.

        // snipped

        return null;

    }, ...);

小结

通过对最小授权的原则的讨论，我想和你分享两点个人看法：

在编码的过程中，要考虑代码的权力；

权力的设计和使用，要遵循“优先最小权限”的原则。

一起来动手

代码权力的设计，是我们容易忽视的一个问题。即便是熟知的 Java 修饰符，也不是每个人每次都能运用得恰如其分。如果你观察 OpenJDK 的代码评审，可能会发现，代码的权力是代码评审者关注的一个重要评审点。恰当运用 public、private 和 final 这些修饰符，可以有效地提高代码的安全性和可维护性。

这一次的练习题，我们换个角度，来分析下面这段代码中的权力设计问题。

import java.util.HashMap;

import java.util.Map;

public class Solution {

    /**

     * Given an array of integers, return indices of the two numbers

     * such that they add up to a specific target.

     */

    public int[] twoSum(int[] nums, int target) {

        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {

            int complement = target - nums[i];

            if (map.containsKey(complement)) {

                return new int[] { map.get(complement), i };

            }

            map.put(nums[i], i);

        }

        throw new IllegalArgumentException("No two sum solution");

    }

}

欢迎你把你的看法写在留言区，我们一起来学习、思考、精进！

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

40 | 规范，代码长治久安的基础

如果从安全角度去考察，软件是非常脆弱的。今天还是安全的代码，明天可能就有人发现漏洞。安全攻击的问题，大部分出自信息的不对称性；而维护代码安全之所以难，大部分是因为安全问题是不可预见的。那么，该怎么保持代码的长治久安呢？

评审案例

有些函数或者接口，可能在我们刚开始写程序的时候，就已经接触，了解，甚至熟知了它们，比如说 C 语言的 read() 函数、Java 语言的 InputStream.read() 方法。我一点都不怀疑，我们熟知这些函数或接口的规范。比如说，C 语言的 read() 函数在什么情况下返回值为 0？ InputStream.read() 方法在什么情况下返回值为 -1？

我知道，我们用错 read() 的概率很小。但是今天，我要和你讨论一两个不太常见，且非常有趣，的错误的用法。

让我们一起来看几段节选改编的 C 代码，代码中的 socket 表示网络连接的套接字文件描述符（file descriptor）。 你能够找到这些代码里潜在的问题吗？

#include <stdio.h>

#include <string.h>

#include <unistd.h>

#include <sys/socket.h>

void clientHello(int socket) {

    char buffer[1024];

    char* hello = "Hello from client!";

    send(socket, hello, strlen(hello), 0);

    printf("Hello message sent\n");

    int n = read(socket, buffer, 1024);

    printf("%s\n", buffer);

}

#include <stdio.h>

#include <string.h>

#include <unistd.h>

#include <sys/socket.h>

void serverHello(int socket) {

    char buffer[1024];

    char* hello = "Hello from server!";

    int n = read(socket, buffer, 1024);

    printf("%s\n", buffer);

    send(socket, hello, strlen(hello), 0);

    printf("Hello message sent\n");

}

#include <stdio.h>

#include <string.h>

#include <unistd.h>

#include <sys/socket.h>

void serverHello(int socket) {

    char buffer[1024];

    char* hello = "Hello from server!";

    int n = read(socket, buffer, 1024);

    if (n == 0) {

        close(socket);

    } else {

        printf("%s\n", buffer);

        send(socket, hello, strlen(hello), 0);

        printf("Hello message sent\n");

    }

}

现在，我们集中寻找 read() 函数返回值的使用问题。为了方便你分析，我把一个标准的 read() 函数返回值的规范摘抄如下：

RETURN VALUES
If successful, the number of bytes actually read is returned. Upon reading
end-of-file, zero is returned. Otherwise, a -1 is returned and the global
variable errno is set to indicate the error.

上面三段代码里，read() 函数的返回值使用都有什么问题？ 上面的函数能够实现编码者所期望的功能吗？

案例分析

上述代码可以作为教学示范的一部分，它们简洁地展示了套接字文件描述符的一些使用方法。但是，这些代码离真正的工业级产品的质量要求还有很大的一段距离。当然了，如果你把上述的代码运行一万次，那么这一万次可能都不会辜负你的期望；运行一百万次，一百万次也可能都是成功的。但是，不论是理论上还是实际上，这些代码还是有可能出现错误的，它们并不是可靠的代码。

问题出在哪儿呢？如果我们仔细阅读 read() 函数返回值规范，可以注意到，read() 函数的返回值是实际读取的字节数。一段信息，套接字的底层实现可能会分段传输，分段接收。所以，read() 函数并不能保证一次调用就返回完整的一段信息，传送和接收也未必是一一对应的，即使这段信息很短。

在上述的例子中，如果期望接收到的信息是“Hello from server!”，那么一次 read() 函数的执行，实际接收到的信息可能是完整的信息，也可能是一个开头的字母“H”。套接字的底层实现并不能保证通过调用一次或者两次 read() 函数，就能够接收到这条完整的信息。

这其实带来了一个不小的麻烦。如果调用 read() 函数的次数无法确定，那么接收端就要一直读取，直到接收到完整的信息。可是，什么样的信息才是完整的信息呢？接收端似乎并没有办法知道一条信息是否完整。

比如在上面的例子中，对于接收端来说，怎么知道“H”不是一条完整的信息， “Hello”也不是一条完整的信息，而“Hello from server!”就是一条完整的信息呢？无法判断信息的完整性，就会面临信息丢失或者读取阻塞的问题。所以，应用层面的设计，必须考虑如何检验接收消息是否完整。比如，对于 HTTP 协议而言，请求行必须以“CRLF”结束。那么，接收端读取到“CRLF”，就能够确定请求行的数据传输完整了。

在实际运行中，如果信息足够短，比如上面的“Hello from server!”，那么套接字底层的实现和网络环境，大部分情况下都能够一次传输完整的信息。所以，上述代码运行一万次，可能这一万次都是成功的。即便如此，也不能保证每次传输的都是完整的信息。

这里面还有另一个不太小的麻烦，是关于 read() 函数的实现的。函数的规范要求，数据传输结束（End-Of-File）后，read() 函数应该返回 0。那么，read() 函数返回 0，是不是就表示数据传输结束呢？ 是的。不然的话，应用程序如何判断数据传输结束又是一个大麻烦。

可是，的确存在类似的实现，读取操作返回了 0，但是数据传输才刚刚开始。下面我要给你讲的这个例子，就是这样的一个看似微不足道，但后果却很严重的问题，把互联网协议的重要安全变更，耽搁了整整十年。

十年的死局

安全套接字协议（ Secure Socket Layer, 简称 SSL）是用来确保网络通信和事务安全的最常见的标准。现在只要你使用互联网，几乎就是这个标准的使用者。这个标准最初由网景公司（NetScape）设计并且实现，后来移交给了国际互联网工程任务组（The Internet Engineering Task Force，简称 IETF）管理，并且更名为传输层安全协议（Trassport Layer Security，简称 TLS）。

我们通过浏览器输入，并且传输到网站的用户名和密码必须只有我们自己知道，不能在传输的过程中被第三者窃取，也不能传送给指定网站以外的服务器。一般来说，浏览器和服务器之间需要建立安全传输连接。这样，网站的真实性是经过校验的，浏览器和网站之间传输的所有数据都是经过加密的，只有我们自己和网站服务器可以解密、理解传输的数据。

传输层安全协议就是用来满足这些安全需求的。那它是怎么做到呢？传输层安全协议需要使用一系列的密码技术，来保证安全连接的建立。

保证数据的私密性使用的是数据加密技术。其中，影响最大的一类数据加密技术使用的是一种叫作链式加密 (Cipher Block Chaining，CBC) 的模式。简单地说，就是前一个加密数据的最后一个数据块，被用来作为后一个数据块加密的输入参数。这样，就形成了后一个加密数据依赖前一个加密数据的链条。

1999 年 1 月，传输层安全协议第一版发布，一般简称为 TLS 1.0。TLS 1.0 使用链式加密模式作为其加密传输数据的一个技术方案。TLS 1.0 获得了巨大的成功。我们很难想象如果没有 TLS 协议，互联网会是一个什么样子。然而，完美的东西，渴求不来也偶遇不到。

2001 年 9 月的密码学进展大会上，一位密码学研究者（Hugo Krawczyk）发表了一篇论文，该论文研究了链式加密的缺陷，以及对于 TLS 协议的影响。利用链式加密的缺陷，攻击者可以破解出加密密码，使用这个密码，就可以解密加密的传输数据，从而获取传输信息。从此，链式加密，一个有着最广泛影响的技术，开始淡出历史舞台。然而，这个进程非常缓慢，非常缓慢。在新技术替代的过程中，老技术的现有问题，以及新老技术的衔接，会出现很多非常复杂和棘手的问题。原有的技术使用得越多，部署得越广泛，这些问题越复杂。

2002 年，OpenSSL，一个被广泛使用的实现传输层安全协议的类库，发布了针对链式加密缺陷的安全补丁和缺陷报告。这个解决方案的目的就是打破链式加密模式的链条，在数据块之间插入随机数据。由于随机数据插到了加密数据链之间，解决了链式加密模式的上述缺陷，这使得链式加密的形式和算法得以保留。

幸运的是，TLS 协议的设计恰巧允许这种使用方法，那么 TLS 协议在理论上仍可以继续使用。既然是随机数据，那就是没有任何意义的数据，不能用于实际的应用，接收端必须忽略这些随机数据。TLS 协议通过传输一个空数据段，然后再传输有效数据，就可以达到添加随机数据的目的。 在理论上，这是一个很好的解决方案。然而，现实比想象的还要精彩。

该解决方案的真正落地，需要 read() 函数或者类似的方法有一个好的实现。在接收到空数据段所代表的随机数据时，需要忽略该数据段，继续等待真正有效的数据，不能返回 0。为什么不能返回 0 呢？还记得上面的 read() 返回值规范吗？返回值为 0，代表数据传输结束，应用程序就不应该继续使用该通道了，后续的数据都会被丢弃。可是对于这个解决方案，如果 read() 返回 0，意味着真正的数据传输才刚刚开始，而不是结束。

如果这样的实现存在，那么这个解决方案不但没有解决安全缺陷问题，还直接导致应用程序不能继续使用。

有没有这样一时糊涂的实现呢？ OpenSSL 的缺陷报告里，提到了一个这样的糊涂的实现。有这么有一个产品，名字的简写是 MSIE。曾经，它是一种特立独行般的存在，到了哪里，哪里就会绽放出不一样的烟火。考虑到 MSIE 及其相关家族产品巨大的市场使用份额，谁采用该安全缺陷修复解决方案，谁就自绝于市场，自绝于广大的用户。遇到了这种巨大的互操作性问题后，OpenSSL 随后缺省关闭了这个安全漏洞修补方案。随后，其他公司，比如 Google 也曾经尝试在他们的产品中做类似的安全修复，都因为这种灾难性的互操作性问题而放弃。安全诚可贵，自由价更高！

对于这样糊涂的实现而言，这只能算是一个芝麻蒜皮的小问题。修复这样的问题也应该不是多么困难的事情。可是，真正的困难在于，这样的产品已经有了非常广泛的用户群体，以及产品部署，包括个人计算机、自动取款机、商超收银机以及银行柜员机等各种各样的形式。

很多产品的部署形式使得产品的升级非常困难，更别提还有很多产品的实现，是以固件的方式存在的了。比如我们家里用的路由器，部署在计算机房里的交换机，以及每辆汽车里的计算机，这些都是升级非常困难的产品。用户越广泛，部署越广泛，升级就越困难，安全变更面临的挑战就越大。芝麻蒜皮的小问题，都可能构筑困难的障碍，带来巨大的风险，从而造成严重的损失。

可能你会有疑问，我换一个浏览器不就没事了吗？如果服务器使用的是这样糊涂的实现，那么一个浏览器是没有办法访问这样的服务器提供的服务的。如果这样的服务器被广泛使用，那么一个浏览器的合理策略，就是不开启这种安全缺陷修复。很多网站不能访问的浏览器，是一个不会有人使用的浏览器。

那么，我自己的服务器是不是可以启动这个安全修复呢？问题又回到了客户端，如果客户端使用了这样糊涂的实现，它也没有办法访问修复了的服务器。如果这样的客户端被广泛使用，比如说最流行的浏览器，那么一个服务器的最合理策略就是不开启这种安全修复。假如一个网站有很多用户不能访问，这实在不是网站设计者和拥有者的初衷。

看起来，这似乎是一个死局！

当时的共识是，针对该漏洞的攻击并不会轻易得手，所以即使不修复该漏洞，估计也不是一个多大的问题。同时，针对该漏洞的升级协议也有条不紊地开始了。

2006 年，经过 4 年的反复敲打，传输层安全协议版本 1.1 发布，一般简称为 TLS 1.1。TLS 1.1 的一个重要的任务，就是解决链式加密的缺陷。然而，任何一个标准从制定到落实，都有一段很长的路要走。TLS 1.1 并没有得到业界及时的响应和应有的重视。携带着安全缺陷的 TLS 1.0 依然统治着传输安全的世界，似乎大家并没有觉得有太多的不妥之处。 时间来到了十年后，2011 年 9 月。

无奈的少数派

针对链式加密安全漏洞的攻击真的不会轻易得手吗？2010 年，一个年轻人（Juliano Rizzo），在印度尼西亚的海滩上阅读了 OpenSSL 的缺陷报告。在优美的印尼海滩上，他发现了一种可能非常有效的攻击方法。

2011 年 9 月，两位天才般的研究人员（Juliano Rizzo，Thai Duong）表示，给他们几分钟时间，他们就可以利用该漏洞入侵你的支付账户。他们给这个攻击技术取了一个超酷的名字，BEAST。你要是搜索一下“the BEAST attack”，就知道这是一个多么轰动的攻击技术。

他们的研究成果受到了密码学家的高度赞美。但是业界厂商的处境就比较尴尬了。毕竟，这是他们十年前尝试修复，但是最后不得不放弃修复的漏洞。十年后的今天，原来阻碍这个漏洞修复的现实障碍，并没有减少。原计划 2011 年 7 月份公开发表论文的日期，不得不推迟。 因为直到 7 月份，还是没有合适的修复方案。这让人感到有些失望，有些沮丧。

7 月 20 日，事情有了转机。

如果传输空数据段不被接受，那么传输一个字节呢？空数据的 read() 实现可能返回 0，一个字节的 read() 实现应该毫无例外地返回 1。在 TLS 1.0 的链式加密模式下，传输一个字节时，有足够随机的数据插入链式加密数据块之间，简单有效地打破链式加密模式的链条。基于这个想法，7 月 20 日，一个通常被称为 1/n-1 分割的解决方案被提出，并且得到了验证。

由于该方法简单有效，主流厂商迅速采纳了这个方案，发布了对应产品的安全补丁。幸运的是，TLS 1.0 续命了十年，业界有更多的时间完成产品的升级换代。不幸但也在预料之中的是，该方案也不是一点兼容性影响都没有。

比如我们案例中讨论的代码，就出了大问题。预期收到一条完整的信息“Hello from server!”。 使用了这个安全补丁后，就必须要接收被分割的两条信息，“H”以及“ello from server!”。如果应用不能处理分割的信息，就不能好好工作了。

幸运的是，虽然不能处理分割信息的应用依然存在，但是数量很少。而且，这是应用自身的问题，很难抱怨安全补丁的不是。由于主流的厂商拥抱了 1/n-1 分割法，而存在问题的应用又是少数派，这些少数派不得不亲手解决他们自身的问题。否则就面临着应用不得不停工的损失，或者承受安全攻击的风险。

对于某一个特定的问题来说，一旦我们成为少数派的一部分，就有可能面临软件安全的风险，以及在兼容性方面做妥协。对于接口规范来说，我们应该严格遵从白名单原则，没有明文规定的行为规范，就不是能依赖的行为规范。

小结

通过对这个评审案例的讨论，我想和你分享下面几点个人看法。

对于应用接口（API）的使用，一定要严格遵守规范，小失误可能造成大麻烦；

对于应用接口（API）的定义，一定要清晰简单，描述一定要详实周到。如果使用者对规范的理解感到困难或者困惑，可能会带来难以预料的问题；

对于应用接口（API）的实现，一定要在规范许可范围内自由发挥。越是影响广泛的实现，越不要逾越规范的界限。

这是一个特殊的案例，我们好像聊了一个故事。对这个案例，你还有什么看法吗？

一起来动手

我们讨论了 read() 函数返回值的问题，可是上述的案例，还有其他的问题存在。你还发现了什么问题？这些问题该怎么更改？你可以使用 Java 或者你熟悉的语言来修改。这可并不是一个简单的修改，我知道你一定会遇到很多问题，欢迎留言分享你的修改或者问题。

如果让你给 clientHello() 或者 serverHello() 加上规范描述，你会怎么描述？你会用什么样的文字，告诉这个接口的使用者，该怎么正确地使用这个应用接口？这同样不是一个简单的小练习，欢迎分享你的规范描述。

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

41 | 预案，代码的主动风险管理

上一次，我们聊了保持代码长治久安的基础——代码规范。这一次，我们接着聊第二个方面，代码的风险预案。

有些问题，并没有适用于各种场景的解决办法；有些设计，并不能适用于所有的用户；有些实现，并不能经受过去、现在和未来的检验。在你的日常工作中，有没有这样的情况出现？

做好预案，是我们管理风险的一个很重要的手段。代码的安全管理，也需要预案。

评审案例

让我们一起来看一段节选的 Java 代码变更。

     public static String[] getDefaultCipherSuites() {

-        int ssl_ctx = SSL_CTX_new();

-        String[] supportedCiphers = SSL_CTX_get_ciphers(ssl_ctx);

-        SSL_CTX_free(ssl_ctx);

-        return supportedCiphers;

+        return new String[] {

+                "SSL_RSA_WITH_RC4_128_MD5",

+                "SSL_RSA_WITH_RC4_128_SHA",

+                "TLS_RSA_WITH_AES_128_CBC_SHA",

                 // snipped

+            };

     }

对于这段代码，我先做一些说明。其中，“Cipher Suites”指的是我们在前面一篇文章中提到的 TLS 协议的密码算法族， “SSL_RSA_WITH_RC4_128_MD5”是一种基于 RC4 加密技术的算法族，“TLS_RSA_WITH_AES_128_CBC_SHA”是一种基于 CBC（Cipher Block Chaining，链式加密）模式的算法族。

getDefaultCipherSuites() 这个方法返回值的顺序，就是 TLS 协议使用这些算法的优先级别。比如，变更后的代码，“SSL_RSA_WITH_RC4_128_MD5”算法族具有最高的优先级。相应地， “SSL_RSA_WITH_RC4_128_SHA”具有第二优先级。在安全传输的连接中，优先级靠前的算法会获得优先考虑。 一旦优先的算法被采用，其他的算法就不会被使用了。

这段代码是 Andriod 系统的一部分。这个修改发生在 2010 年 5 月份，这样做是为了使用 Andriod 偏爱的 RC4 加密算法。有了这样的变更，Andriod 就能对算法的选择有更好的安排与控制。

想想上一篇文章中我们说到的 BEAST 攻击，这个修改是不是很有前瞻性？ BEAST 攻击技术是在 2011 年 9 月份公布的，有缺陷的算法是基于 CBC 模式的算法。Andriod 提早了一年把涉及问题的 CBC 模式设为次优选择。Chrome 浏览器可能更早做了类似的修改。所以，当 BEAST 攻击技术公开后，Google 可以很自豪地说：“我们很早就优先使用更安全的 RC4 算法啦。”

可是，这个变更，还是有一点小问题的。

案例分析

要想看清楚这个问题，我们还需要讲述一段小插曲。

在 1999 年设计 TLS 1.0 的时侯，有两种常用的加密算法类型。一个是分组加密技术，把原数据分成若干的小块，然后一小块一小块地分组加密。3DES 是二十世纪九十年代最流行的分组加密算法。另一个是流加密技术，这种加密方式是把原数据一位一位地运算。RC4 是二十世纪九十年代最流行的流加密算法。对这两种算法，TLS 1.0 都是支持的。其中的分组加密算法，TLS 1.0 采用的是链式加密模式。

2011 年 9 月 25 日，BEAST 攻击技术公开发表。通过上一篇的介绍我们都知道，BEAST 攻击技术针对的就是链式加密模式，链式加密模式不再安全了。你有没有惊喜地发现，TLS 1.0 的设计真是周到，居然还有一个流加密技术可以使用，而且 RC4 算法被广泛支持。这真是一个可以救命的设计。

如果你回看 2011 年、2012 年的安全分析文章，很多业界的专家都会推荐使用 RC4 来替代链式加密模式，很多产品也开始变更为优先使用 RC4 算法。毕竟，BEAST 攻击是一个不可忽视的安全问题，而针对 BEAST 攻击的补救措施并不是一个完美的解决方案。在业界寻找链式加密模式的替代算法的同时，优先使用 RC4 算法似乎可以让大家喘口气。

这的确是一个救命的设计，但是，这是一个巧合的设计吗？ 如果身处 1999 年，我还没有足够的经验来判断这样的设计是有意为之，还是仅仅是一个巧合。但是，20 年后的今天， 如果我们的产品只支持一种模式的安全算法，我一定如坐针毡。因为我知道，短则一两天，长则三五年，一个算法的理论模型或者实现方式几乎一定会被破解。战战兢兢地等待着这个算法被破解，然后再去寻找补救的措施，显然不是一个可以让工程师心情愉悦、身心放松的好选择。

虽然优先使用 RC4 可以让业界稍作喘息，但是，好景并不长。2013 年 3 月 13 日，一个研究小组公开了一个关于 RC4 算法的严重的安全漏洞。不同寻常的是，这一次并没有合适的修改 RC4 算法的补救措施。该研究小组建议，停止使用 RC4，TLS 1.0 和 1.1 版本的用户应该转化到 CBC 模式的加密算法。这算是一个不小的玩笑，很多应用刚从 CBC 模式切换到 RC4 算法不久，就要重新调整，再切换回去。

这就类似于两个病例。CBC 模式虽然是一场大病，可是，有成熟的救治方案。虽然那里或者这里或许会留个疤，可是手术一旦实施成功，CBC 模式照样活蹦乱跳。 这就好比以前的 100 米需要跑 9.8 秒，手术后也可以跑个十一二秒的。虽然离巅峰阶段有点差距，但是问题不算大。

而 RC4 的问题，就像是医生诊断后，直接重症监护，并时刻准备后事了。冷酷而又无奈！2013 年 3 月 13 日，RC4 算法宣告重病缠身，重症监护。

随后，业界开始重新转换回 CBC 模式，很多应用开始禁用 RC4 算法。2013 年 8 月，IETF 提出了在 TLS 协议中禁用 RC4 算法的议案。2015 年 2 月，该议案获得通过。 RC4 算法这个因高效、安全而著名的算法，从 2013 年 3 月开始，慢慢淡出人们的视野。

有了上面的小插曲，你知道上面案例代码的问题了吗？ 这段代码写死了 TLS 协议算法的缺省优先级别。除非更改代码，否则这个缺省优先级别是无法更改的。一旦优先的算法出了问题，代码修改虽然简单，但是已部署产品的升级，有时候就是一件很复杂的事情。

世事无常，一个好的设计，需要有双引擎和降落伞。

双引擎，长远之计

现代的客机，一般采用双引擎甚至多引擎设计。如果其中一个引擎失灵，依靠其他的引擎依然可以延程飞行。 有人戏称延程飞行是一个“要么多引擎，要么去游泳”的设计理念。但是，延程飞行时间也是有约束的，比如不得超过 90 分钟。为什么呢？ 因为延程飞行时，就只有一个发动机在工作了。单引擎运转，总是有更大的安全隐患，这实在是让人不安！

需要注意的是，双引擎不是备份计划，不是应急计划，不是 Plan B，两个引擎日常都要使用。如果其中一个引擎闲置，那么当真正需要它的时候，我们就不知道它的状态如何，是否可以承担重任。

想一想，为什么 CBC 模式出事的时候，业界可以切换到 RC4 算法？ RC4 算法出事的时候，业界可以切换回 CBC 模式？ 其中有很重要的两点值得考虑。

无论是 CBC 模式，还是 RC4 算法，都是实际投入使用的算法。

无论是 CBC 模式，还是 RC4 算法，都是大部分应用同时支持的算法。

这两条，对于 CBC 模式和 RC4 算法之间的成功的切换，都是必不可少的隐性条件。

如果我们理一理 TLS 协议发展的脉络，就随时可以看到双引擎设计的理念的运用。

1999 年，TLS 1.0 提供了 CBC 模式和 RC4 算法两种加密算法。随后，2003 年，发现了 CBC 模式的安全问题。 2006 年发布的 TLS 1.1 在协议设计层面修复了 CBC 模式的潜在问题，提供了 CBC 模式和 RC4 算法两种加密算法。2008 年发布的 TLS 1.2 添加了 AEAD 加密算法，加上已被修复的 CBC 模式和 RC4 算法，这样就有三种加密算法可供选择。2018 年 8 月发布的 TLS 1.3，废弃了 CBC 模式和 RC4 算法，只保留了 AEAD 算法，但是 AEAD 算法有两个推荐选项，分组密码的 GCM 模式，和流密码的 Chacha20/Ploy1305 模式。到 2018 年 8 月，TLS 协议在这二十年里，逐步废弃了二十年前最流行的算法。但是在整个过程中，一直保持多算法并存的设计。

如果你熟悉 JDK 的安全规范和实现，可能会注意到，对于每一个类型的算法，我们总是尽可能地提供多种选择。如果一个算法面临问题，我们总是尽快地替换旧算法，并且补充新的算法。这样，尽快地结束单算法的延程飞行状态。所以，提供多种选择，不仅仅是为了提升丰富性，也是为了在面临关键风险的时候，有风险控制的办法。

对于生死攸关的风险点，我们要有双引擎设计的意识。 然而，也有双引擎解决不了的问题。即便是多引擎飞机，也需要备用降落伞。

降落伞，权宜之计

在上述案例的代码中，算法的缺省优先级别是固定的。 一旦优先的算法出了问题，该怎么办？ 如果等到出了问题、蒙受了损失，再去寻找解决方案，就太晚了。一般情况下，一个好的软件应该备好降落伞，提前设计部署好这些意外风险的应急办法。我们永远不希望使用降落伞，但是如果有意外发生，降落伞的存在就非常必要了。随时需要，随时就可以拿来使用。

以 JDK 为例，对于 TLS 协议的密码算法，一旦一个算法出现问题，修改源代码，替换掉出问题的算法是 JDK 提供的常规解决方案。另外，JDK 还提供了多样的应急方案：

修改 JVM 系统的安全参数（Security Property），降低出问题算法的优先级；

修改 JVM 系统的安全参数（Security Property），废弃出问题算法；

修改 JVM 系统的安全参数（Security Property），升级到没有问题 TLS 版本；

修改应用的系统属性（System Property）, 使用指定的算法；

修改应用的系统属性（System Property）, 升级到没有问题的 TLS 版本。

JVM 系统的安全参数可以控制运营在 JVM 上的所有应用程序，而应用的系统属性一般只影响使用它的应用程序。

在 JDK 中，可以通过修改<java-home>/conf/security/java.security文件设置 JVM 系统的安全参数。比如，“jdk.tls.legacyAlgorithms”是一个设置 TLS 历史遗留算法的安全参数。一旦一个算法被设置为历史遗留算法，这个算法就不会被优先使用，除非不存在其他可替换的算法。如果我们把 RC4 算法设置为历史遗留算法，它的优先级就被降到最低，即使它的缺省优先级别是最高的。

jdk.tls.legacyAlgorithms = RC4_128

一旦在 java.security 文件中设置了这个参数，所有使用这个 JDK 配置的应用程序都会受到影响。

一个应用程序运行时，可以指定系统属性，比如：

$ java -Djdk.tls.client.protocols="TLSv1.3" myApp

那么这个应用程序就使用 TLS 1.3 版本的客户端。 另外一个运行的程序，也可以使用 TLS 1.2。 两个运行程序的设置互不影响。

$ java -Djdk.tls.client.protocols="TLSv1.2" myApp

通过上面的例子，你可以看到，这些应急方案采用了配置参数的方式，使用非常简单，不需要运营代码的更改。简单、易用、快速上手，这是我们设计应急降落伞的一个思路。

一旦一个系统采纳了双引擎和降落伞的设计，系统的可靠性和抗风险能力往往会有大幅度提高。 可是，这并不是白白得来的。它同时也意味着软件研发的巨大投入，和软件复杂度的显著提升。

我们总是尽最大的可能使得软件程序简化、简化再简化。可是对于生死攸关的风险点，我们有时需要选择相反的方向，强化、强化再强化。不是所有的复杂都是必要的，也不是所有的复杂都是不必要的。软件的设计，是一个需要反复权衡、反复妥协的艺术。

小结

通过对这个评审案例的讨论，我想和你分享下面两点个人看法。

尽管我们无法预料未来可能出现的风险，但是软件的设计和实现依然要考虑抗风险的能力。

对于生死攸关的风险点，我们既要有长期的双引擎设计的意识，也要有权宜的应急预案；

如果深入到软件的架构和设计里，双引擎和降落伞的使用随处可见，你愿意分享你见到过的双引擎和降落伞的案例吗？欢迎在留言区留言。

一起来动手

这不算是一个练习，而是一个请求。如果你有时间，你能够研究下你使用的语言、架构或者应用，找找其中的风险防范设计吗？ 代码安全和风险控制，是一个需要超大范围合作的技术领域。我们也需要共同创作这一话题，共同学习其中的经验。

比如说，我有个疑问就是，很多业务需要手机验证码，当我的手机不能使用时，我还有没有办法操作我的银行账户？

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

42 | 纵深，代码安全的深度防御

前面我们聊了保持代码长治久安的两个策略，代码规范和风险预案。这一次，我们接着聊代码安全管理的另外一个策略：纵深防御。

说起纵深防御（Defence-in-Depth），我们最常想到的是军事战略。在军事上，这个概念指的是通过层层设防，以全面深入的防御来延迟敌人的进攻，通过以空间换时间的方式来挫败敌方的攻击。这有别于一战定胜负的决斗思维。决斗思维需要集中所有的优势资源在最前线，一旦前线失守，整个战争基本就宣告结束了。

信息安全的攻防，有一个很重要的特点，就是不存在没有漏洞的防线。按照决斗思维部署的信息安全防御体系，也许仅仅只能是个心理安慰。事实上，现代网络安全防御体系和应用架构，不管你是否意识到，已经在广泛使用纵深防御的思想了，或多或少，或明或暗。

评审案例

我们一起来看一段 OpenJDK 的代码修改。其中 wrap() 方法的传入参数 key 是一个不能泄露的密钥，而 key.getEncoded() 导出这个密钥的编码，以便进行下一步的加密操作。有时候，密钥的编码可以等同于密钥，也是不能泄露的。你知道这样修改的必要性吗？

 byte[] wrap(Key key)

         throws IllegalBlockSizeException, InvalidKeyException {

     byte[] result = null;

+    byte[] encodedKey = null;

     try {

-        byte[] encodedKey = key.getEncoded();

+        encodedKey = key.getEncoded();

         if ((encodedKey == null) || (encodedKey.length == 0)) {

             throw new InvalidKeyException(

                     "Cannot get an encoding of " +

                     "the key to be wrapped");

         }

         result = doFinal(encodedKey, 0, encodedKey.length);

     } catch (BadPaddingException e) {

             // Should never happen

+    } finally {

+        if (encodedKey != null) {

+            Arrays.fill(encodedKey, (byte)0x00);

+        }

     }

     return result;

 }

这个代码变更，是对临时私密缓冲区的更积极的管理。

案例分析

我们知道，如果一段存储空间不再使用，一般而言，操作系统或者应用程序仅仅是“忘记”或者“删除”这段存储空间的索引，并不清理存储空间里的具体内容。我们常说“释放”一段内存空间。我觉得“释放”这个词使用很贴切。释放后，那段内存空间还在，模样也没有变化，内容也没有什么变化，只是被释放了，被丢弃了。

上面的例子中，encodedKey 是一个临时变量，定义它的方法调用返回，encodedKey 就被释放了。在这段代码变更之前，encodedKey 这段存储空间里的数据，在释放前和释放后，并没有变化。至于这段空间里的数据什么时候被覆盖，则完全依赖于 Java 和操作系统的内存管理机制，以及后续的内存使用。这种不确定性就存在一些隐患。

一段内存空间被释放后，由于这段内存空间的内容还在，这些内容就有可能被未授权的用户拣到、看到。如果这些内容是私密或者敏感的信息，比如密钥、口令、社会保障号码等，那么它们的泄露就是严重的安全事故。

假设还有一个程序，该程序分配了一段内存。那么，这段内存里可能就有别的程序释放、丢弃的内容。这个程序就有可能分析、转存、打印这些内容，进而造成上一个程序的私密信息的泄露。

我们在前面讲过整数的溢出，如果可以远程地制造整数溢出或者其他类型的内存溢出，这段内存空间的信息也可能会被泄露。

实际应用中有很多提高内存使用效率的技术，比如说缓存、虚拟内存、闪存、内存管理技术等等。这些技术在提高效率的同时，也增加了系统的复杂性，加剧了诸如内存溢出这类风险的破坏性。

高效率，是一个让人不懈追求的目标。为了高效率，对于大部分数据的释放，我们可以采取撒手不管的策略；为了安全，对于小部分敏感数据的释放，我们需要采取非常保守的策略。敏感数据归零，就是其中的一个保守策略。

敏感数据归零是不是可以绝对避免敏感数据被泄露呢？当然不是，敏感数据归零也有很多解决不了的问题。 比如说，对于上面例子中的 encodedKey，理论上还有以下的风险：

在 encodedKey 归零之前，发生了内存溢出，encodedKey 有可能包含在被泄露的内存信息中。

在 encodedKey 归零之前，底层的内存管理技术拷贝了 encodedKey 所在的内存块，当然也拷贝了 encodedKey 的内容。这时候，encodedKey 归零，有可能并没有清除拷贝的内容。

在 encodedKey 归零时，由于不可控的编译器优化，encodedKey 的归零操作并没有真正地及时执行。

看起来真的像筛子一样，到处都是窟窿。那么，敏感数据归零到底还有什么意义呢？敏感数据归零虽然存在这样那样的问题，但是已经显著地降低了我们上面所说的风险。如果没有及时把敏感数据归零，风险会更大。敏感数据归零是纵深防御体系中，非常具有深度的一个防线，但并不是唯一的防线。

怎么设计和部署纵深防御体系呢？这是一个无比巨大的话题，我们没有办法几篇文章就交代清楚。下面，我们就尝试来理清楚这背后的逻辑。然后，你可以按照这些逻辑，去寻找相关的技术和实践，把它们运用到你的项目中去。

防线，攻击路径的纵深

一个有效的攻击，必须处于一个特定的攻击场景中。对应的防御，就是阻断攻击者到达或者创建这个特定的场景。比如说，上面的例子中，敏感数据的泄露需要攻击者能够访问敏感数据所在的内存。为了设置纵深防御体系，我们需要在离内存十万八千里的地方就开始布防。

为了形象一点，我们来看看一个有着“八道防线”的防御场景：第一道防线就是没有人可以接近存放这台计算机的街区，除了居住在街区里的人；第二道防线就是没有人可以接近存放这台计算机的建筑物，除了工作在建筑物里的人；第三道防线就是没有人可以接近存放这台计算机的房间，除了可以在该房间工作的人；第四道防线是没有人可以登录这台计算机，除了该计算机的操作者；第五道防线是没有人可以安装卸载计算机上的程序，除了该计算机的系统管理员；第六道防线是没有人可以访问内存空间，即便是系统管理员；第七道防线是没有人可以查看敏感数据，不管是谁；第八道防线是如果有人查看了敏感数据，或者泄露了敏感数据，隐私保护法会在前面等候。

这么严防死守，敏感数据还有可能被泄露吗？ 有可能。遗憾的是，无论是在理论上还是在实践上，还是会有人冲破这些防线。 因为每一道防线都不是完美的，每一道防线都有天然的漏洞，而且每一道防线都需要执行才能发挥作用。这确实让人不满、不安。甚至有人说，这种基于攻击路径的布防已经老掉牙了，因为人们发现，世界上 80% 以上的攻击都是由于应用程序的漏洞引起的。可是，要是没有这些防线，实际有效攻击的数量一定会有数量级的爆发性增长。

机制，发挥防线的作用

任何一道防线都不会自动发挥作用，除非我们设置了让防线良性运转的机制。比如说，针对第一道防线，我们有什么办法让只有居住在该街区的人进入该街区？ 谁来守卫这道防线？谁来检查、监管这道防线的有效性？如果非街区的人进入，应该采取什么措施？

还记得我们在第二篇文章里，谈到的优秀代码出道前需要经历的重重关卡吗？这些关卡是软件质量保障的一部分，也是让防线机制良性运转的一部分。比如我们上面提到的第七道防线，没有高质量的代码，这道防线的质量就是值得担忧的。

你不妨想想看，对于每一道防线，都应该设置什么样的机制？ 每一道防线都考虑决策、执行、监督这三项权力的分配，以及计划、执行、检查、纠正这四项操作的实际执行。这可能需要花费很长时间，也绝对不是一件容易的事。我相信，把这些问题弄清楚，理明白，哪怕只是针对其中的一道防线，都是一个了不起的成就。

多样，加固防线的双保险

最后，再给你介绍一个防护利器，那就是增加防护的多样性。什么是防护的多样性呢？

想一想你的车有没有安装警报器？ 如果说车门锁和点火锁是两道防线的话，那么安装警报器就可以增加防线的多样性。警报器是独立于锁的另外一种形式的防护技术。安装有警报器的车不仅试图阻止陌生人使用这辆车（锁），还警告试图使用这辆车的陌生人（警报）。这就是防护的多样性。

比如上面案例中的 encodedKey，如果代码再长一点，我们就需要在最早的时间执行归零操作，而不是等待系统的垃圾回收机制发挥作用。这也是多样性的一点点体现，它需要时间和空间上的双重考虑。

这里我要特别提醒你，一定要注意多样性之间的独立性。我们再来看小区门禁这个例子。一个封闭小区的入口不仅有门禁系统，还有门卫人员。这也算是多样性的防护措施吧。可是，我见到的封闭小区的管理，几乎门卫人员总是可以开启门禁系统。门禁系统和门卫人员并不是完全独立的。有些时候，这不是强化了防线，而是弱化了防线。只要和门卫人员搞好关系，门禁系统就是虚设。在计算机系统中，系统管理员、数据库管理员的权限就有点像门卫这个角色。如果信息系统防护的多样性之间不能独立，多样性的防护实际上可能会产生多样性的漏洞。

小结

通过对这个评审案例的讨论，我想和你分享下面两点个人看法。

没有防御纵深的信息系统，其安全性是堪忧的；

一个防御体系，需要考虑纵深和多样性，更需要确保防御体系良性运转。

一起来动手

把纵深防御理念用得最讲究的，在我的见识范围内，我认为是核防护的设计，毕竟这是关乎巨大人群生死存亡的大事。如果要梳理基本概念，树立谨慎保守的防护理念，我建议阅读下核防护的一些规范。 你可以从核电站通用设计准则开始。

那么，怎么把理念落实到应用软件呢？我建议你阅读 Oracle 的纵深防御指南。这份指南虽然是为 Oracle 数据库准备的文档，但是其中涉及的很多思想、方法和技术同样适用于其他的软件和编码领域。非常难得的是，这个纵深防御指南罗列了不同维度的很多检查点。我们可以使用这些检查点来查验我们的软件设计和实现。

聊起纵深防御这个话题，就真的不是几篇文章可以说的完的。希望今天的内容能够抛砖引玉，欢迎你把自己的经验和看法写在留言区，我们一起来学习、思考、精进！

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

43 | 编写安全代码的最佳实践清单

像以前一样，当大家看到“最佳实践清单”这个标题的时候，就意味着这一个模块又到了总结的时候了。

这一模块我们从代码安全的角度出发，探讨了如何编写安全的代码。首先我们再来重温一下，为什么需要安全的代码呢？

为什么需要安全的代码？

1. 代码质量是信息安全的基础

大部分的信息安全事故，是由软件代码的安全缺陷引起的。没有安全质量保证的代码，建立不起有效、可信的信息系统。信息系统的安全，主要依赖的不是信息安全技术专家，而是我们每一个编写代码的工程师。

2. 安全漏洞的破坏性难以预料

直到真实的安全问题发生之前，我们都难以预料软件的安全漏洞到底有多大的破坏性。一个小小的安全漏洞，如果被攻击者抓住了时机，就可以瞬间摧毁多年的苦心经营和良好声誉，把公司推到舆论的风口浪尖，甚至使公司面临毁灭性的风险和挑战。

3. 安全编码的规则可以学得到

由于安全攻击技术的快速发展，安全编码涉及到的细节纷繁复杂，安全问题的解决甚至需要大规模、大范围的协作。编写安全的代码不是一件轻而易举的事情。但是，安全编码的规则和经验，却是可以学习和积累的。使用必要的安全管理工具，开展代码评审和交流，也可以加速我们的学习和积累，减少编写代码的安全漏洞。

要想掌握安全编码的技术，熟练修复软件漏洞的实践，我们需要跨过意识、知晓、看到三道关卡。面对最新的攻击技术和安全问题，通过每一道关卡都障碍重重。我们要主动地跟踪安全问题的最新进展，学习最新的安全防护技术。

及时更新自己的知识，掌握难以学习到的知识和技能，也是构建和保持我们竞争力的一个重要办法。

编写安全代码的基本原则

1. 清楚调用接口的行为

使用不恰当的接口，是代码安全风险的主要来源之一。我们一定要了解、掌握每一个调用接口的行为规范，然后在接口规范许可的范围内使用它们。不要去猜测接口的行为方式，没有明文规定的行为，都是不可靠、不可信的行为。

2. 跨界的数据不可信任

跨界的数据面临两大问题：一个问题是数据发送是否可信？另一个问题是数据传递过程是否可靠？这两个有任何一个问题不能解决，跨界的数据都可能被攻击者利用。因此使用跨界的数据之前，要进行校验。

3. 最小授权的原则

信息和资源，尤其是敏感数据，需经授权，方可使用。所授予的权力，能够让应用程序完成对应的任务就行，不要授予多余的权力。

4. 减小安全攻击面

减小、简化公开接口，缩小可以被攻击者利用的攻击界面。比如，设计更简单直观的公开接口，使用加密的数据传输通道，只对授权用户开放服务等等，这些措施，都可以减少安全攻击面。

5. 深度防御的原则

使用纵深防御体系防范安全威胁。要提供深度的防御能力，不能仅仅依靠边界的安全。编写代码，要采用谨慎保守的原则，要解决疑似可能出现的安全问题，要校验来源不确定的数据，要记录不规范的行为，要提供安全的应急预案。

安全代码的检查清单

安全管理

有没有安全更新的策略和落实计划？

有没有安全漏洞的保密共识和规范？

有没有安全缺陷的评估和管理办法？

软件是不是使用最新的安全修复版？

有没有定义、归类和保护敏感信息？

有没有部署多层次的安全防御体系？

安全防御能不能运转良好、及时反应？

不同的安全防御机制能不能独立运转？

系统管理、运营人员的授权是否恰当？

有没有风险管理的预案和长短期措施？

代码评审

数值运算会不会溢出？

有没有检查数值的合理范围？

类、接口的设计，能不能不使用可变量？

一个类支持的是深拷贝还是浅拷贝？

一个接口的实现，有没有拷贝可变的传入参数？

一个接口的实现，可变的返回值有没有竞态危害？

接口的使用有没有严格遵守接口规范？

哪些信息是敏感信息？

谁有权限获取相应的敏感信息？

有没有定义敏感信息的授权方案？

授予的权限还能不能更少？

特权代码能不能更短小、更简单？

异常信息里有没有敏感信息？

应用日志里有没有敏感信息？

对象序列化有没有排除敏感信息？

高度敏感信息的存储有没有特殊处理？

敏感信息的使用有没有及时清零？

一个类，有没有真实的可扩展需求，能不能使用 final 修饰符？

一个变量，能不能对象构造时就完成赋值，能不能使用 final 修饰符？

一个方法，子类有没有重写的必要性，能不能使用 final 修饰符？

一个集合形式的变量，是不是可以使用不可修改的集合？

一个方法的返回值，能不能使用不可修改的变量？

类、方法、变量能不能使用 private 修饰符？

类库有没有使用模块化技术？

模块设计能不能分割内部实现和外部接口？

有没有定义清楚内部数据、外部数据的边界？

外部数据，有没有尽早地完成校验？

有没有标示清楚外部数据的校验点？

能不能跟踪未校验外部数据的传送路径？

有没有遗漏的未校验外部数据？

公开接口的输入，有没有考虑数据的有效性？

公开接口的可变化输出，接口内部行为有没有影响？

有没有完成无法识别来源的数据的校验？

能不能不使用序列化技术？

序列化的使用场景，有没有足够的安全保障？

软件还存在什么样风险？

有没有记录潜在的风险问题？

有没有消除潜在风险的长期预案？

有没有消除潜在风险的短期措施？

潜在的风险问题如果出现，能不能快速地诊断、定位、修复？

小结

学会编写安全的代码，是一个优秀的、专业的软件工程师的核心竞争力之一。与规范、经济的代码相比，安全的代码有很多不同的特点。

代码不规范和效率不高，业务也可以运转，然后慢慢优化，逐渐演进。但代码一旦出现安全问题，遭受攻击，损失立即就会反映出来，而且破坏性极大。

代码不规范，看的人立刻就会觉得很难受。代码的效率不高，业务运转不通畅，同样会有及时的反馈。就代码的安全层面来说，一般情况下直到攻击发生之前，我们可能都不知道代码是否存在安全问题。等到攻击真实发生的时候，损失已经成为事实了。

代码的规范原则，是一个相对容易掌握的内容。高效的代码，也有很多成熟的经验可以学习。可是，代码的安全，却是一个攻易守难的问题。哪怕我们今天知道了所有的攻击和防护方法（这当然不可能），如果明天出现了一种新的攻击手段，而且全世界只有一个人知道，我们的系统都存在潜在的安全威胁。

编写安全的代码，需要掌握复杂的知识，而且需要大规模的合作。我们之前提到过三道槛，具体展开来是这样的：

我们要想掌握安全编码的技术，熟练修复软件漏洞的实践，需要先过三道关。
第一道关，是意识（Conscious）。也就是说，要意识到安全问题的重要性，以及意识到有哪些潜在的安全威胁。
第二道关，是知晓（Awareness）。要知道软件有没有安全问题，安全问题有多严重。
第三道关，是看到（Visible）。要了解是什么样的问题导致了安全漏洞，该怎么修复安全漏洞。

在意识、知晓、看到这三道关面前，我们要打开自己的视野，保持强烈的好奇心，从全世界范围内学习成熟的经验、先进的技术以及最新的进展。

其中，最重要的资源是 NIST 提供的安全漏洞数据库。这个数据库的使用方式有两种：第一种是了解自己的系统有没有最新的安全漏洞；第二种是学习最新的安全威胁的攻击方法和防范技术。

一起来动手

我们今天的练手题，就学着使用 NIST 的安全漏洞数据库。请你从这个数据库里，选择一个或者几个安全漏洞，试着看一下你的系统有没有类似的安全威胁？这个安全漏洞的攻击方式是什么样的？这个安全漏洞的问题出现在哪里？该怎么防范？

欢迎你在留言区留言、讨论，分享你的阅读体验。

如果你觉得这篇文章有所帮助，欢迎点击“请朋友读”，把它分享给你的朋友或者同事。

44 | “代码安全篇”答疑汇总

到这一篇文章，意味着专栏第三模块“安全的代码”也更新完毕了。今天，我来集中解答一下留言区里的一些疑问。

@醉侠

希望老师后面能多讲讲安全编码的例子或者推荐好的书籍，这块儿确实是很大的弱点。

答：不同于其他的编码技术，编码安全是一个在攻与防的拉锯战中持续发展的领域。新的攻击技术花样翻新，防守技术也跟着变化。因此，编码安全的技术和技巧也纷繁复杂。CWE 的常见安全问题列表，到目前为止，已经列举了 1131 种问题。其中每一个问题，都会给软件带来难以预料的安全风险。由于安全问题和相应的技巧数量如此巨大，学习几种或者十几种编码安全的技巧，能起到的作用并不是很大。

但坐以待毙显然不是我们的风格。安全问题的数量和技巧虽然庞大，但是基本的原理数量并不是很多，而且都很直观。比如，只要你记住，“跨界的数据不可信任”这条简单的原理，并且去校验每一个跨界数据的有效性，就消除了代码的很大一部分风险。至于什么是有效的数据，什么是有害的数据，每一个场景都有不同的定义。当你想要践行这条原理的时候，你总能定义数据的有效性，找到检查的办法。记住了这一条原理，面对跨界数据的时候，你就会加倍警觉，跟踪数据检查点，想办法校验数据。

所以，我能给的第一个建议就是：记住最基本的安全编码原理。

遗憾的是，我们掌握每一样技术的过程，都是先有量变，才会有质变。就比如说，“跨界的数据不可信任”这条原理，想要把它变成我们编码时的下意识行为，就需要很多的锻炼。刚开始的时候，要去学一些技巧，然后照葫芦画瓢。慢慢地，不需要葫芦，自己也能画好瓢了。再慢慢地，你想画啥画啥，不管是瓢还是壶。到最后，引领你的思路的，就是基本的原理，而不再是纷繁复杂的技巧。你也就从按照技巧来编码的阶段，转变到根据问题和基本的原理，去寻找技巧，解决问题的新阶段。

比方说吧，你知道了一个数据校验的技巧，整数不能太大。于是，遇到跨界的整数数据，你都会检查这个整数是不是太大。这就是一个很好的实践。如果遇到浮点数呢？如果遇到用户密码呢？如果遇到 SQL 查询语句呢？你会不会想到浮点数不能太大，用户密码不能太长，SQL 查询语句也不能太长？遇到不同的场景，不同的数据，这时候最能给你提供帮助的，就是记住“跨界的数据不可信任”这条原理。然后去想办法去检验整数、浮点数、用户密码、SQL 语句。

从哪里开始积累这个“量变”的量呢？我能给的第二个建议是：学习编程语言的编码规范以及安全编码指南。

一门编码语言的规范，通常会告诉大家编码的最佳实践。这些最佳实践，就包括安全编码的内容。比如C/C++语言的编码规范里，就有如何处理整数、浮点数、内存的很多技术和技巧。

如果你学习的是 Java，Java 的安全编码指南是一定要掌握的内容。Java 的安全编码指南是 Java 安全组对潜在的 Java 编码安全问题的总结，既有原则，又有示例。无论是设计还是实现，我们都要把安全编码指南考虑进来。

Java 语言已经有二十多年的历史了，每更新一个版本，它的安全编码指南都会加入新的内容。这也是安全攻防发展的表现之一。每隔一段时间，总会有新的攻击技术出现，总会有新的安全编码实践。所以，每一个 JDK 版本推出的时候，我们还要检查一下新版的安全编码指南，看看有没有新加的内容。然后，看看我们的代码，有没有需要调整的地方。

一般情况下，每一门语言都会有编码规范和安全指南。其中，一个常用的资源是卡内基梅隆大学的SEI CERT 系列的总结。你自己也找找看，有没有你最喜欢、最适合的资源。

如果你掌握了编码规范和安全编码指南，那就是一个了不起的成就了。但是，对于一个你编写的软件的安全性而言，这还不够。因为，安全的攻防技术是一个持续发展的技术。今天还是安全的系统，明天也许就不安全了。所以，我们也要跟得上变化。

因此，我能给的第三个建议是：跟踪、学习、使用最新的安全编码进展。

由于安全漏洞的保密性要求，关于安全编码的最新技术的资源相对来说是稀缺的。一般来说，安全补丁出来的时候，我们大部分人知道的就是：安全补丁出来了，系统需要升级，运维有的忙了。对于研发人员来说，最重要的信息其实是安全补丁补了啥？安全问题在哪儿？是怎么修复的？我们的系统有没有类似的问题？不是所有的安全补丁都会披露安全问题的细节，所以有时候，我们需要去看源代码，去推测到底有什么安全问题，去推测为什么补丁会起作用。

但是这样做太花费时间，幸运的是，有很多人这样做，并且分享了他们的研究成果。我们要做的就是，根据安全问题，使用搜索引擎，搜索相关的研究，然后分析、检查、使用到我们自己的代码里。如果没有现成的研究成果，我们就需要自己动手分析这些安全补丁。

知晓安全问题存在的最重要资源，是 NIST 的安全漏洞数据库，我建议你一定要用好这个数据库。

我知道这并不容易。我们都期望的模式是，培训几个小时，然后天下行走。一个好的程序员是时间堆积起来的。只有持续地了解、积累、训练，才能慢慢地到达一个期望的水准，才能建立、巩固自己的技术优势。

我在学习金融课程的时候，经常被各种复杂的数字设计弄得迷迷糊糊。老师最常用的鼓励的话就是：要学习一点难的东西，这样才能走到更远的地方。我把这句话也送给你。

@轻歌赋

有个问题，案例中 hashtable 增加了一个 entryset 后，攻击者如何直接访问对象的 entryset 呢？
以 web 程序为例的话，我想不出用户如何传入可以执行的代码，能过直接让权限检查的调用对象直接执行 entryset，也看不出对方如何能够重写我服务端的代码或者继承并且被 jvm 加载。
老师能给个实际的例子吗？

@hua168

如果是 web 程序的话，攻击者是怎么查看我们内部程序？ 如果是 API 接口的话，这些方法我们不是隐藏起来，不公开，它怎么绕过漏洞攻击？

答：上面的这两个问题，是一个比较典型的容易迷惑的地方。要想了解这个问题，我们还需要了解我们曾经讲到的边界问题，以及公开接口本身的问题。

比如 Web 服务吧，用户通过浏览器访问 Web 服务，传输使用的 HTTP 或者 HTTPS 协议，并没有机会直接获取服务器端提供服务的 Java 对象。这样的话，服务器端提供应用服务的 Java 对象，只是系统的一个内部实现，外部接口其实是在 HTTP 层。这就是一个很好的边界隔离。

公开接口的问题在于，我们并不知道一个公开接口到底会在什么环境下使用，也许不是 Web 环境。我们也不知道使用公开接口的代码是不是恶意代码，也许它是不可信任的代码。公开接口要做的就是，不管调用者有什么意愿，接口的实现都按照规范执行，调用者不能改变接口的规范。要不然，就是一大堆的问题。

就拿 VM 虚拟机来说吧，一个 VM 上，可能运行了多个用户，每个用户之间并不相互信任，而且虚拟机也不会完全信任每个用户；可能运行多个应用，每个应用之间也不相互信任，而且虚拟机也不会完全信任每个应用。这些用户、应用，为什么能够运行在虚拟机上呢？唯一的办法，就是调用虚拟机提供的接口，来获得虚拟机的资源。而虚拟机要把用户隔离开来，把应用隔离开来，就需要严格的权限安排和调度。而要想实现这些权限的安排和调度，同样需要通过接口来实现。如果可以越过虚拟机的权限管理，虚拟机上的用户和应用就会面临巨大的安全威胁。

虚拟机及其权限管理的思想，不仅仅只是应用在 JVM 或者云计算。像我们日常使用的浏览器（Firefox、Chrome）、服务器 (nginx、Apache HTTP Server)、打印机，都需要考虑多租户、多任务的问题。

@hua168

像我们开发是直接调用框架函数，如果是安全问题，一般是框架自身的问题吧？

这是一个很好的问题。我们的系统每次都及时升级到最新的安全修复版，是不是就够了呢？

如果所有的代码都能够做到及时地推出安全修复版，包括你自己的代码，这样做就够了。

但是，我们常常遗忘了一点，像框架的代码一样，我们自己写的代码也是代码，也会存在安全问题。成熟框架的安全问题虽然很少，但是出现的安全问题通常引人瞩目。我们自己写的代码，存在的安全问题可能会更多，但是通常没人关注，直到问题突发。

如果你留意一下 NIST 的安全漏洞数据库在一段时间内最新的安全漏洞，你可能会发现，出现问题的代码和应用千奇百怪，涉及的领域和技术非常广泛，不仅仅局限于框架、语言、开源代码。有代码的地方，就有安全问题。

我们勤奋地给系统打补丁，但是很少去审视自己编写的代码是不是存在新的安全问题，很少主动地去修复自己代码里的安全问题。这不是你我的个人问题，这是公司的管理和投入问题。有质量的软件维护是昂贵的。

以上就是这次答疑的内容。如果你还有没有解决的疑问，请在留言区给我留言。

如果你觉得这篇文章解决了你的疑惑，对你有所帮助，欢迎点击“请朋友读”，把这篇文章分享给你的朋友或者同事。

Q&A加餐丨关于代码质量，你关心的那些事儿

专栏上线后，有一些同学对于代码质量有关的问题还不是很清楚，有很多疑问，所以我特意做了一期 Q&A，来回答一下这些问题。

1. 有没有什么技巧可以不费力地查看源代码？

这是一个好问题。但遗憾的是，我们费力的程度，主要取决于代码的作者，而不是我们自己。我想了好久，也没有找到不费力气查看源代码的技巧。

通常我自己用的办法，有时候就像剥洋葱，从外朝里看；有时候也像挖井，找到地表的一小块地儿，朝下一直挖，直到我理解了代码的逻辑关系。

如果你刚开始接触，我建议你先不要看代码，先去看 README，再去看用户指南。先把软件干什么、怎么用搞清楚。然后再去看开发者指南，搞清楚模块之间的关系、功能，理解代码中的示例。最后，再去看代码。

看代码的时候，找一个顺手的 IDE。IDE 丰富的检索功能，可以帮助我们找到一个方法，在什么地方定义的，有哪些地方使用了。

如果你还不知道要看哪一个源代码，先找一个例子开始。不管这个例子是开发指南里的，还是测试代码里的。先找出一个例子，把它读懂，然后阅读例子中调用的源代码。

比如，你要是看到示例代码调用了 Collections.unmodifiableList​() 方法，如果想了解它，就查看它的规范文档或者源代码。从例子开始剥每一个你关心的方法，一层一层地深入下去。

OpenJDK 的代码评审，很多时候代码量很大。代码评审的时候，很多文档还没有出来。我一般是分层看的。先看用户接口设计的这部分代码，这一部分的代码量一般比较少。看完用户接口的设计，才能明白作者的思路和目标。这样，自己就有了一个思路，对代码的方向有了一个大致的了解。然后再看接口实现的代码，看看实现和接口是不是吻合的。这个过程中，我一般会记录下类和方法之间的依赖关系，也会顺着依赖关系来理解代码的逻辑关系。

好的代码，有清晰的分割和层次，逻辑清晰，代码的行文一般也是简单直观，读起来比较容易。不好的代码，阅读起来就费力得多了。

2. 代码质量和工作效率的矛盾如何取舍？

这个问题有一个隐含的假设，就是代码质量和工作效率不可兼得。这本身是个常见的误区。这个误区也给了我们一个看似成立的借口：要么牺牲代码质量，要么牺牲工作效率。

代码质量和工作效率，是不是矛盾的呢？这取决于我们观察的时间、地点以及维度，甚至我们是怎么定义效率的。

如果给我们一个小时的时间，看看谁写的代码多。不顾及代码质量的也许会胜出（只是也许，我们后面再说为什么只是也许）；认真设计、认真对待每一行代码的也许会败北（也只是也许）。

短期内代码写得多与否，我们可以把这个比喻成“走得慢，还是走得快”的问题。

如果给我们半年的时间，那些质量差的代码，编写效率也许可以和质量好的代码保持在同一水准，特别是软件还没有见到用户的时候。

如果给我们一年的时间，软件已经见到了用户，那么质量差的代码的编写效率，应该大幅度落后于优质代码了。甚至生产这些代码的团队，都被市场无情淘汰了。

看谁的代码能够长期赢得竞争，我们可以把这个比喻成“到得慢，还是到得快”问题。

为什么会这样呢？ 一小时内，什么都不管，什么都不顾，怎么能不多产呢！

可是，不管不顾，并不意味真的可以高枕无忧。需求满足不了就会返工，程序出了问题也会返工，测试通不过还会返工······每一次的返工，都要你重新阅读代码，梳理逻辑，修改代码。

有很多时候，你会发现，这代码真是垃圾，没法改了，只有推倒重来。

这个时候再回过头看看这种代码编写的背景，你能说这是一种高效率的行为吗？

这就相当于，一个马拉松比赛，前 1000 米你在前头，后来你就要往回跑。1000 米这个槛，有人跑一次就够了，你要是跑七八次，还谈什么效率呢。这种绝望的事情看似荒唐，其实每天都会发生。

为什么会这样呢？ 因为在软件开发的过程中，遗留的问题需要弥补，这就类似于往回跑。所以，走得快，不一定到得快。

你不妨记录一下三个月以来，你的工作时间，看看有多少时间是花在了修修补补上，有多少时间是花在了新的用户需求上。这样，对这个问题可能有不一样的感受。

另外，是不是关注代码质量，就一定走得慢呢？

其实也不是这样的。比如说，如果一个定义清晰，承载功能单一的接口，我们就容易理解，编码思路也清晰，写代码就又快又好。可是，简单直观的接口怎么来？我们需要花费大量的时间，去设计接口，才能获得这样的效果。

为什么有的人一天可以写几千行代码，有的人一天就只能写几十行代码呢？这背后最重要的一个区别就是心里有没有谱，思路是不是清晰。几千行的代码质量就比几十行的差吗？ 也不一定。

你有没有遇到这样的例子，一个同学软件已经实现一半了，写了十万行代码。另一个熊孩子还在吭哧吭哧地设计接口，各种画图。当这个同学写了十五万行代码的时候，完成一大半工作的时候，那个熊孩子已经五万行代码搞定了所有的事情。你想想，效率到底该怎么定义呢？

那个熊孩子是不是没有能力写二十万行代码呢？不是的，只要他愿意，他也许可以写得更快。只是，既然目标实现了，为什么不去聊聊天，喝喝咖啡呢？搞这么多代码干啥！你想想，效率能用代码数量定义吗？

就单个的程序员而言，代码质量其实是一个意识和技能的问题。当我们有了相关的意识和技能以后，编写高质量的代码甚至还会节省时间。如果我们没有代码质量的意识，就很难积累相关的技能，编写代码就是一件苦差事，修修补补累死人。

有很多公司不愿意做代码评审，效率也是其中一个重要的考量。大家太注重一小时内的效率，而不太关切一年内的效率。如果我们将目光放得更长远，就会发现很多不一样的东西。

比如说代码评审，就可以减少错误，减少往回跑的频率，从而节省时间。代码评审也可以帮助年轻的程序员快速地成长，降低团队出错的机率，提高团队的效率。

有一些公司，定了编写规范，定了安全规范，定了很多规范，就是执行不下去，为什么呢？ 没有人愿意记住那么多生硬的规范，这个时候，代码评审就是一个很好的方法，有很多眼睛看着代码，有反馈，有讨论，有争议，有建议，团队能够更快地形成共识，找出问题，形成习惯，共同进步。看似慢，其实快。

英文里，有一句经典的话 “Run slowly, and you will get there faster”。汉语的说法更简洁，“因为慢，所以快”。

一般情况下，通常意义上的软件开发，如果我们从产品的角度看，我认为高质量的代码，会提升工作的效率，而不是降低工作效率。

当然，也有特殊情况。比如我们对质量有着偏执般的追求，这时候，效率就不是我们的首选项。也有情况需要我们在五秒以内眨眼之间就给出代码，这时候，质量也不是我们的首选项。

代码的质量该怎么取舍呢？这取决于具体的环境，和你的真实目标。

3. 你加入了 Java SE 团队，经历了从 JDK 1.5.0 到 JDK 12 的整个迭代过程，这个阶段中，Java 开发的流程都经历了哪些迭代？

在十多年间，Java 开发的流程有很多大大小小的调整。影响最大的，我觉得主要有三个。

第一个变化是更加开放了。Java 开源以后，不仅仅是把代码开放出来，开发流程也开放了出来。OpenJDK 有详细的开发人员手册，告诉大家怎么参与到 OpenJDK 社区中来。

OpenJDK 开放了 Java 改进的流程，这就是 JEP（JDK Enhancement-Proposal & Roadmap Process）。每一个 Java 的改进，从雏形开始，一直到改进完成，都要经过 OpenJDK 社区的讨论、评审。什么都要经过 OpenJDK 讨论，这效率不是变慢了吗？其实，这种开放反而加快了 Java 的演进。

创新性的想法第一时间就送到用户面前，接受用户的审视。

一个项目是好还是坏？做还是不做？该怎么做？这都在用户可以“挑剔”的范围内。Java 的演进，也从少数的专家委员会模式，变更为小步快走的大集市模式。

OpenJDK 也开放了 Java 代码评审的流程。现在，几乎所有的变更，都是通过 OpenJDK 进行的。为什么要变更？变更的是什么？变更带来的影响有哪些，都要描述得清清楚楚。而且，任何人都可参与评审，都可以发表意见。如果有兼容性的影响，用户会在第一时间发现，而不是等到系统出了问题再来修补。透明化带来的好处就是，有更多的眼睛盯着 Java 的变更，代码的质量会更好，潜在的问题也会更少。

第二个变化是研发节奏更快了。Java 的版本演进，从传统的好几年一个版本，变更为半年一个版本。两三年一个版本的演进模式，使得 Java 的任何改进，都要两三年以后见。即使这些改进已经成熟了，也得在代码库里躺着，到不了用户的场景里。没有用户反馈，产品的质量也就没有经过真实的检验了，没有改进的真实建议。这其实是一种浪费，效率会变低。

第三个变化是自动化程度提高了。现在，OpenJDK 提交的每一个变更，都会自动运行很多的测试。如果这个变更引起了测试错误，测试系统会给参与者发邮件，指明出错的测试，以及潜在的怀疑对象。变更提交前，我们也可以发出指令，运行这些测试。这些自动化的测试，可以提高代码的质量，减轻工程师的压力，提高工作的效率。

4. 您是 JDK 11 TLS 1.3 项目的 leader，在这个项目中，你对代码安全又是怎么理解的呢？

代码的安全，我一直以为是一个见识问题。一个安全问题，你见识到了，认识到了，你就会在代码里解决掉。没有认识到安全问题，可能想破脑袋，也不知道问题出在哪。

比如说，TLS 1.3 废弃掉了密码学的很多经典算法，包括 RSA 密钥交换、链式加密算法等。如果去你去查看经典的密码学教材，你会发现这些算法都被看做牢不可破的算法，全世界的每一粒沙子都变成 CPU，也破解不了它们。

可是，站在 2019 年再来看这些算法，各有各的问题，有的破解也就是几分钟的事情。那我们还应该使用这些算法吗？当然要想办法升级。可现实是，你根本不知道这些算法已经有问题了。当然，也想不到去升级使用这些算法的应用程序。这就是我们说的见识。知道了，你才能想到去解决。

见识是一个坏东西，需要我们看得多、见得多，才能够拥有。甚至，需要付出代价，比如遭受到黑客攻击。

见识也是一个好东西，见得越多，看得越多，你构筑起来的竞争优势就越明显。随着阅历的增长，见识也会增强，竞争力就提高了。

如果一个东西，每个人三秒就可以掌握，那当然是好的。但同时，它就算不上你的优势了。即使有优势，也只是三秒钟的差距。

另一个常见的问题，就是认为安全的代码牺牲效率。

编写安全的代码，会不会牺牲工作的效率呢？一般情况下，这对效率的影响几乎可以忽略不计。 比如说，一个公开接口，我们不应该信任用户输入的数据，应该检查用户输入数据的边界和有效性。做这样的检查，增加不了多少代码量，反而会让我们的思路变得清晰。再编写具体的业务逻辑的时候，编码的效率就变高了，甚至还会减少代码量。

就拿 TLS 1.3 来说，当废弃掉一些经典的算法时，一幅全新的画面就出现在我们面前。TLS 协议的设计更加简单，更有效，效率也会翻倍地提升。

代码质量、工作效率、软件性能、代码安全，这些东西可以作为基准，但是不适用拿来对比。如果非要单纯地从概念上对比，看看有没有冲突，没有一点儿现实意义。安全的代码会牺牲软件性能吗？ 不一定。重视代码质量，就会牺牲工作效率吗？也不一定。

今天挑了几个同学的问题来回答。其实关注代码质量这种事情，就像爬山一样，每个人都会，但不是所有人都能爬到最后。会当凌绝顶，一览众山小。当自己在山峰上爬得越来越高的时候，再回过头，你会发现自己和身边的人已经不一样了。

如果你觉得这篇文章对你有所启发，欢迎你点击“请朋友读”，把它分享给你的朋友或者同事。

45 | 尾声：如何成为一个编程好手？

从刚开始准备这个专栏的时候算起，到这一篇文章和大家见面，已经有十个月的时间了。

这个专栏设立的愿景，是想要传达编写优秀代码的理念，帮助软件工程师快速成长并且保持长久的竞争力。但是，四十多篇文章，显然不是通天的秘籍。一个软件工程师的修炼，主要还是靠日积月累的积累和精进。而且，这个修炼还包括编码之外的功夫。

了解问题的领域

我们编写代码的目的，是要解决具体的问题，而不是为了写出好看的代码。这就需要我们至少了解两样东西：编码的工具和具体的问题。无论是编码工具，还是具体的问题，都需要专业知识。一般来说，一个优秀的程序员，这两样东西都要学，都要会，而且还能把它们结合起来。

世界上的编程语言屈指可数，如果只会几门编程语言，不管你多么精通这门语言，都很难保持长久的竞争力。这主要是因为，编程语言的门槛很低，掌握编程语言的人太多。这也就意味着，在市场竞争中，你要超越的人会有很多。

而具体的问题领域，则是一个多姿多彩的世界。证券交易、银行结算、商品流通、教育培训、安全防护等，各有各的道理，各有各的规则。

作为一个软件工程师，如果你熟悉银行结算的专业知识，你就成为了懂得编码和银行结算的复合型人才。你可以在编码工具的领域出类拔萃，也可以在银行结算领域出类拔萃。但是，市场分工已经不需要你在这两个领域都是顶尖高手了。只要在这个结合部位，是个顶尖高手就可以了。而这个结合部位的竞争者的数量，就没有那么大的基数了。

所以对于软件工程师来说，特别是那些年轻的软件工程师，我们不要把自己局限代码的编写者这个角色上。我们需要把自己的角色拓展成问题的解决者。

定位成代码的编写者，你的目标可能就是写好代码，学好工具。定位成问题的解决者，你的目标可能就变成使用代码，解决好问题。

问题解决者这一定位，会让你更有好奇心去了解问题的来龙去脉，去发掘问题背后更多的东西。这样，你就能自己推动自己，成为该问题领域的专业人士，然后成为这个结合部的复合型人才。

扩大“无用”的见识

编写代码，本身是一个创造性的过程。在这个过程里，很多过去看起来没有多大用处的见识，可能就会派上用场。而这些“无用”的见识，如果过去你没有认识，没有积累，它们一定不会在你需要的时候让你灵光乍现，也不会在你需要坚持的时候给你力量。所谓的灵光乍现，其实都是埋在你脑海深处的见识。

我们前面讨论过不少编码的原则，比如说“简单直观”和“最小授权”。直觉上，这些虚无缥缈的东西和编码本身没有直接的联系。可是，这些小小的原则往往决定着产品的市场成败。你怎么抵御住复杂性、丰富性以及代码权力的诱惑？说实话，如果没有很坚定的理由和开阔的视野，我们很难抵御住这些诱惑。而见识和信念，来源于你对于生活的理解，包括对人的理解。

所以，除了技术之外，我希望你有时间、有精力、有能力去做其他的事情。读书、旅游、做饭、遛娃、种地、修车，各种爱好，都是积累，都是人生百态。

平衡安身的生活

当我准备这篇文章的时候，996.ICU 的讨论真是一浪高过一浪。希望普遍的 996 只是一个特殊时段的特殊产物。

长期超负荷的工作时间能不能换来超额的工作产出，这是一个很大的疑问。不过，我可以确定的是，每个人一天都只有 24 小时，我们有限的精力只能在这 24 小时内腾挪。在一段时间里，我们做了这件事情，就丧失了做另外一件事情的机会。获得了这件事的好处，就丧失了另一件事的好处。这还有一个专业的名词，叫“机会成本”。

996 的安排，让我们付出了多大的代价，丧失了多少机会？对于具体的每个人，答案也许有所不同。但是对于整个群体和大部分人，这代价也许会超出我们的想象。它所造成的长期的社会问题和个人问题，也许要很多年后才会显现。

希望 955 早日正常化，这需要大家的共同努力。

即使正常的工作时间只是一种奢望，我希望你还能够谈恋爱、陪家人。

谢谢你陪我一起走过了这个 45 期的专栏！