mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-20 08:03:43 +08:00
del
This commit is contained in:
133
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/17 | 为什么需要经济的代码?.md
Normal file
133
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/17 | 为什么需要经济的代码?.md
Normal file
@@ -0,0 +1,133 @@
|
||||
<audio id="audio" title="17 | 为什么需要经济的代码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7c/d7/7c1fb49cbbfa6e58c7ba215d562c14d7.mp3"></audio>
|
||||
|
||||
如果你在线购买过春运的火车票,经历过购票网站的瘫痪,你应该深有体会,网站瘫痪是一件让人多么绝望的事情。
|
||||
|
||||
根据有关报道,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");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
146
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/18丨思考框架:什么样的代码才是高效的代码?.md
Normal file
146
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/18丨思考框架:什么样的代码才是高效的代码?.md
Normal file
@@ -0,0 +1,146 @@
|
||||
<audio id="audio" title="18丨思考框架:什么样的代码才是高效的代码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/09/5c/092467be63911988f66a2d4183ad695c.mp3"></audio>
|
||||
|
||||
如果让你设计一个有十亿用户使用的售票网站,你会考虑哪些问题?如果让你设计一个有一万亿用户使用的服务,你又会考虑哪些问题?不要以为有一万亿个用户的服务离我们很远,它正在快速地逼近我们。
|
||||
|
||||
我们前面讨论了,代码的性能是关于如何管理内存、磁盘、网络和内核等计算机资源的。该怎么衡量这些资源管理的好坏呢?这就需要一些评价指标。
|
||||
|
||||
这些指标不仅指导着代码的交付标准,也指导着我们编码时的技术选择。
|
||||
|
||||
## 用户的真实感受
|
||||
|
||||
最直接的指标就是用户的真实感受。用户的感受是我们软件开发最基本的风向标,当然也是代码性能追求的终极目标。
|
||||
|
||||
如果去超市买东西,我们享受的是购物的过程,讨厌结账。结账之所以令人讨厌,一小部分原因在于这时我们要付钱,更大的原因在于这个过程排队时间可能会很长。如果再算错了帐,就更让人不爽了。
|
||||
|
||||
用户对于软件性能的要求,和我们超市结账时的要求差不多:等待时间要短,出错的概率要小。
|
||||
|
||||
**等待时间要短**
|
||||
|
||||
这个概念很好理解。等待时间越短,我们越喜欢。最好是一点儿都感觉不到等待时间。使用“感觉”、“快”、“慢”这种词汇,有点主观了。有一种统计方法,被广泛地用来评价应用程序性能的满意度,它就是应用程序性能指数(Apdex)。
|
||||
|
||||
根据任务的响应时间,应用程序性能指数定义了三个用户满意度的区间:
|
||||
|
||||
<li>
|
||||
**满意**:如果任务的响应时间小于T,用户感觉不到明显的阻碍,就会比较满意;
|
||||
</li>
|
||||
<li>
|
||||
**容忍**:如果任务的响应时间大于T,但是小于F,用户能感觉到性能障碍,但是能够忍受,愿意等待任务的完成;
|
||||
</li>
|
||||
<li>
|
||||
**挫败**:如果任务的响应时间大于F或者失败,用户就不会接受这样的等待。挫败感会导致用户放弃该任务。
|
||||
</li>
|
||||
|
||||
在互联网领域,最佳等待时间(T)和最大可容忍等待时间(F)的选择有着非常经典的经验值,那就是最佳等待时间是2秒以内,最大可容忍等待时间是最佳等待时间的4倍,也就是8秒以内。
|
||||
|
||||
有了统计数据,应用程序性能指数可以按照下属的公式计算:
|
||||
|
||||
>
|
||||
Apdex = (1 × 满意样本数 + 0.5 × 容忍样本数 + 0 × 挫败样本数) / 样本总数
|
||||
|
||||
|
||||
假如有一个应用,100个样本里,有70个任务的等待时间在2秒以内,20个任务的等待时间大于2秒小于8秒,10个任务的等待时间大于8秒。那么,这个指数的就是80%。
|
||||
|
||||
>
|
||||
<p>Apdex = (1 × 70 + 0.5 × 20 + 0 × 10) / 100<br>
|
||||
= 0.8</p>
|
||||
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
113
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/19 | 怎么避免过度设计?.md
Normal file
113
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/19 | 怎么避免过度设计?.md
Normal file
@@ -0,0 +1,113 @@
|
||||
<audio id="audio" title="19 | 怎么避免过度设计?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ee/b5/eea1bc090afd925d985e05306019e9b5.mp3"></audio>
|
||||
|
||||
俗话说,“过犹不及”。“过度”这个词仿佛会给我们一些不好的暗示。不要紧张,我们先聊一个轻松的话题。
|
||||
|
||||
假设有一个小地方,要建一个火车站。这个地方有数十万人口,每列火车预计上下乘客数十人,高峰时段大概近百人。你会怎么设计这个火车站?
|
||||
|
||||
这个火车站可能是个富丽堂皇的建筑,有宽敞的售票厅和候车室。这种设计到处可见,你可以想一想你熟悉的火车站, 也可以观察一下旅途中的火车站。
|
||||
|
||||
也有些火车站可能只是一个一百平米左右的小房子,只有简单的售票窗口、进站口和出站口。 比如说北京的清华园火车站,就是这样的。
|
||||
|
||||
也有的火车站只有标牌、售票机和遮阳棚的一小块地方,告诉人们火车在这儿停靠,就像我们常见的公交车站。
|
||||
|
||||
这三种火车站,都能实现旅客购票、候车、上车和下车的核心需求,帮助他们实现乘车旅行的目的。
|
||||
|
||||
既然乘坐火车的核心需求基本是一样的,为什么车站的差别这么大呢?
|
||||
|
||||
乘车旅行这个需求,衍生出了购票、候车、上车和下车的需求。
|
||||
|
||||
购票的需求衍生出了售票、购票、验票、检票以及各个环节排队的需求。
|
||||
|
||||
售票的需求衍生出了要有售票办公室和售票大厅、管理售票人员、购票人员和票贩子的需求。
|
||||
|
||||
售票办公室衍生出了科长办公室、科员办公室、会议室、售票窗口。售票窗口的需求也可以接着衍生出更多的需求。这个列表我们可以列很长很长,最后的结果就是火车站的建设耗资大,建设周期长,运营成本高。
|
||||
|
||||
哪一种火车站对旅客更方便呢?如果在一个小地方,那么第三种火车站旅客上车的环节最少,是最方便的。而且投资小,建设周期短,运营成本低。
|
||||
|
||||
软件开发和建火车站一样,都有设计、建设、运营和维护的环节。该怎么管理好需求和设计,是工程设计者需要重点考虑的问题。
|
||||
|
||||
## 避免需求膨胀
|
||||
|
||||
软件开发过程中,最让人痛苦的是什么?如果有这么一个调查的话,“频繁的需求变更”应该是一个高票选项。
|
||||
|
||||
频繁的需求变更确实让人抓狂。它变更的可不仅仅只是需求,还有不断重构的代码,不断延长的工期,不断增长的投入,以及越来越多的加班。
|
||||
|
||||
在一个五彩缤纷的世界里,拥有多种多样的观点,坚持不懈地改进,是一件好事情。但是,“多姿多彩”对于计算机程序而言,就是个巨大的挑战。现实世界需要丰富,而抽象程序则需要简化。这对不可避免的矛盾,就成了所有程序员的心头刺。
|
||||
|
||||
软件是为现实服务的,而现实总是变化的。作为程序员,我们是没有办法抵制住所有的需求变更的。为了限制无节制的需求变更,适应合理的需求进化,我们要使用两个工具,一个工具是识别最核心需求,另一个工具是迭代演进。
|
||||
|
||||
**识别最核心需求**
|
||||
|
||||
一个经济的系统,需要从小做起,而不是一上来就胡子眉毛一把抓,什么都要做。什么都要做的结果是什么都做不好。
|
||||
|
||||
要从小做起,最重要的就是选择。什么是必须做的?什么是现在就要做的?这是我们做选择时,要时刻准备提出和回答的两个问题。
|
||||
|
||||
回答这两个问题,有时候并不容易。我们知道的越多,见识越广,这两个问题越难回答。比如说开头中提到的火车站的建设。既然建造公交车站一样的火车站又方便、又省钱,为什么还要建造富丽堂皇的火车站呢?岂不是又费事又费钱?
|
||||
|
||||
但是,专家有他们的考量。逃票问题、安全问题、舒适问题、管理问题、就业问题等,都在他们的考虑范围内。
|
||||
|
||||
作为程序员,或者项目经理,我们懂得一大把的原理,学了一大把的技术,手里有一大把工具。这些技术运用起来,就是一个丰富的大世界。我们的很多需求,来源于心里的推断,而不是眼前的事实。推断产生需求,催生的系统就会形成新的事实,强化推断的演进。为了解决了不存在的问题,我们制造出真实存在的问题。
|
||||
|
||||
我第一次见到像公交车站一样的火车站时,心里想,这也算火车站吗?多多少少有点震惊。我真的没有见过这么简单的火车站。有一段时间,我每天都要经过这个车站,也没发现什么不妥的地方。只要提前30秒到达火车站,就能赶上准时出发的火车,像坐公交车一样很方便。我之所以觉得它方便,因为我是乘客。
|
||||
|
||||
如果从最终用户的眼里看软件,类似于从乘客的眼里看火车站。很多软件,承载了太多中间客户的期望和推断,最终用户的真实需求和关键需求反而被膨胀的无效需求弱化了。
|
||||
|
||||
所以,我们要回归到最终用户。只有从最终用户的眼里看需求,才能够识别什么是最核心的需求,什么是衍生的需求,什么是无效的需求。这样,我们才能找到一个最小的子集,那就是现在就必须满足的需求。
|
||||
|
||||
首先就必须满足的需求,是优先级最高的、最重要的事情,这些事情要小而精致,是我们的时间、金钱、智力投入效率最高的地方,也是回报最丰厚的地方。我们要把这些事情做到让竞争对手望尘莫及的地步。
|
||||
|
||||
**不要一步到位**
|
||||
|
||||
有一些需求很重要,但不是现在就必须做的。这就需要另外一个方法——迭代演进。第一次我们没有办法完成的事情,就放在第二次考虑。
|
||||
|
||||
迭代演进不仅仅需要考虑上一次没有完成的事情,还要考虑变化促生的新需求。所以,在这一步,还要像第一次一样,先找到最小的子集,也就是现在就必须满足的需求。然后,全力以赴地做好它。
|
||||
|
||||
这样迭代了几轮之后,一定有一些第一次看起来很重要的需求,再看反而不重要了,根本就不需要解决。
|
||||
|
||||
在OpenJDK社区中,每年都会关闭一些有些年头的需求请求。这些需求,要么没有真实用户,要么已经有了替代的解决方案,要么就是已经被抛弃的技术。所以一些曾经看起来值得考虑的需求,时间为我们过滤掉了它们。
|
||||
|
||||
是不是迭代的时候,就可以考虑一些不重要的需求了呢?不,永远不要考虑不重要的需求。有时候,遏制住添加新功能、新接口的渴望,是一个困难的事情。我们需要学会放手,学会休假,以及拥有空闲时间。
|
||||
|
||||
管理好需求,是提高我们的工作效率以及软件效率最有效路径。但遗憾的是,我们不是总有机会决定软件需求的范围,以及优先顺序。
|
||||
|
||||
幸运的是,我们是产品的设计者和生产者,代码该怎么写,我们有很多话语权。
|
||||
|
||||
## 避免过度设计
|
||||
|
||||
其实和需求一样,设计也是一个容易膨胀的环节。 看看下面的漫画,是不是有些好笑又熟悉?我们只是需要一点盐,设计师会设计一个能给我们任何调味品的接口。设计接口系统会耗费很多时间,但设计师认为这会节省我们未来的时间。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/ed/d5c799ded09527e891c92ec1d931e7ed.png" alt=""><br>
|
||||
遗憾的是,对软件来说,过度设计的接口意味着更多的代码、更多的维护、更多的修修补补,未来也不会节省我们的时间。
|
||||
|
||||
费迪南德·保时捷曾经说过:“一辆完美的跑车,应该首先越过终点,然后立即陷入困境。”这多少有点苛刻,但这就是“少就是多”的极简主义追求。
|
||||
|
||||
过度设计导致过度复杂,过度复杂导致效率降低、危险加剧、性能降低。如果保持简单而不是复杂化,大多数系统都能发挥最佳作用。这就是“少就是多”的魅力。
|
||||
|
||||
避免过度设计,和避免需求膨胀一样,我们要时刻准备提问和回答的两个问题:什么是必须做的?什么是现在就必须做的?
|
||||
|
||||
这两个问题时常提问、经常回答,有助于我们始终在用户的需求范围内思考设计,有助于我们始终关注核心问题,并且保持设计方案的简介、优雅。
|
||||
|
||||
## 小结
|
||||
|
||||
影响代码效率的最重要的两件事情,就是需求的膨胀和过度的设计。为了这两个问题,我们需要回答两个问题:
|
||||
|
||||
<li>
|
||||
什么是必须做的?
|
||||
</li>
|
||||
<li>
|
||||
什么是现在就必须做的?
|
||||
</li>
|
||||
|
||||
弄清楚这两个问题后,我们需要做的,就是做好现在就必须做的事情。
|
||||
|
||||
## 一起来动手
|
||||
|
||||
克制住过度设计的倾向,这需要非凡的自律和自信。有时候我就想,微信的团队到底是怎么克制住自己,让微信简洁的页面保持了这么多年。那么多的诱惑,那么多流量变现的办法,都能抵制住,得要有多强大的内心和清醒的认识!
|
||||
|
||||
微信的聊天页面是我们最关心的信息:谁发送了信息。一对一的聊天界面,永远只使用窄窄的一行,来完成丰富的功能,红包、语音、表情包、贴图,都可以在这一行完成。所有的其他功能,比如小程序,朋友圈、合作商家,都不能干扰核心功能的呈现。现在我们看着可能觉得很简单,其实这样的设计真的很难,真的很了不起。如果不相信的话,我们来做一做练手题。
|
||||
|
||||
这一次的练手题,我想请你思考一个银行账户管理App,有哪些必须要做的事情。作为一个用户,你最关心的账户管理内容是什么?然后,你看下常用银行的App,看一看你最关心的内容,需要多少步操作才可以获得,也想一想哪一些内容你会毫不犹豫地删掉。
|
||||
|
||||
欢迎你在留言区留言,分享你的看法。也欢迎点击“请朋友读”,把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
134
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/20 | 简单和直观,是永恒的解决方案.md
Normal file
134
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/20 | 简单和直观,是永恒的解决方案.md
Normal file
@@ -0,0 +1,134 @@
|
||||
<audio id="audio" title="20 | 简单和直观,是永恒的解决方案" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/4e/c14575c0f58dafc7da8d00cff0b72b4e.mp3"></audio>
|
||||
|
||||
上一次,我们聊了影响代码效率的两个最重要的因素,需求膨胀和过度设计。简单地说,就是找到要做的事情,做的事情要少。接下来,我们来聊聊怎么做这些事情。其中,我认为最重要的原则就是选择最简单、最直观的做法。反过来说,就是不要把事情做复杂了。
|
||||
|
||||
要想简单直观,我们要解决两个问题。 第一个问题是,为什么要简单直观?第二个问题是,该怎么做到简单直观?
|
||||
|
||||
## 为什么需要简单直观?
|
||||
|
||||
简单直观,看似是一条每个人都能清楚明白的原则。事实上,这是一个非常容易被忽略的原则。如果我们没有对简单直观这个原则有一个基本的认识,就不太可能遵循这样的原则。
|
||||
|
||||
我们都喜欢原创和挑战,来展示我们的聪明才智。而简单直观的事情,显而易见的解决办法,似乎不足以展示我们的智慧和解决问题的能力。
|
||||
|
||||
遗憾的是,在软件世界里,一旦我们脱离了简单直接的原则,就会陷入行动迟缓、问题倍出的艰难境地。简洁是智慧的灵魂,我们要充分理解这一点。
|
||||
|
||||
**简单直观是快速行动的唯一办法**
|
||||
|
||||
我们真正要的不是简单直观的代码,而是轻松快速的行动。编写简单直观的代码只是我们为了快速行动而不得不采取的手段。有一个说法,如果面条代码能够让我们行动更快,我们就会写出面条代码,不管是刀削面还是担担面。
|
||||
|
||||
我见过的优秀的程序员,无一例外,都对简洁代码有着偏执般的执着。甚至小到缩进空格该使用几个空格这样细枝末节的问题,都会严格地遵守编码的规范。乍一看,纠缠于缩进空格不是浪费时间吗?可是真相是,把小问题解决好,事实上节省了大量的时间。
|
||||
|
||||
这些对代码整洁充满热情的工程师,会对整个团队产生积极的、至关重要的影响。这种影响,不仅仅关乎到工程进展的速度,还关系到工程的质量。真正能够使得产品获得成功,甚至扭转科技公司命运的,不是关键时刻能够救火的队员,而是从一开始就消除了火灾隐患的队员。
|
||||
|
||||
**简单直观减轻沟通成本**
|
||||
|
||||
简单直观的解决方案,有一个很大的优点,就是容易理解,易于传达。事情越简单,理解的门槛越低,理解的人越多,传达越准确。一个需要多人参与的事情,如果大家都能够清晰地理解这件事情,这就成功了一半。
|
||||
|
||||
我们不要忘了,客户也是一个参与者。简单直观的解决方案,降低了用户的参与门槛,减轻了学习压力,能够清晰地传递产品的核心价值,最有可能吸引广泛的用户。
|
||||
|
||||
**简单直观降低软件风险**
|
||||
|
||||
软件最大的风险,来源于软件的复杂性。软件的可用性,可靠性,甚至软件的性能,归根到底,都是软件的复杂性带来的副产品。越复杂的软件,我们越难以理解,越难以实现,越难以测量,越难以实施,越难以维护,越难以推广。如果我们能够使用简单直接的解决方案,很多棘手的软件问题都会大幅地得到缓解。
|
||||
|
||||
如果代码风格混乱,逻辑模糊,难以理解,我们很难想象,这样的代码会运行可靠。
|
||||
|
||||
## 该怎么做到简单直观?
|
||||
|
||||
如果我们达成了共识,要保持软件的简单直观,那么,我们该怎么做到这一点呢?最重要的就是做小事,做简单的事情。
|
||||
|
||||
**使用小的代码块**
|
||||
|
||||
做小事的一个最直观的体现,就是代码的块要小,每个代码块都要简单直接、逻辑清晰。整洁的代码读起来像好散文,赏心悦目,不费力气。
|
||||
|
||||
如果你玩过乐高积木,或者组装过宜家的家具,可能对“小部件组成大家具”的道理会深有体会。代码也是这样,一小块一小块的代码,组合起来,可以成就大目标。作为软件的设计师,我们要做的事情,就是识别、设计出这些小块。如果有现成的小块代码可以复用,我们就拿来用。如果没有现成的,我们就自己来实现这些代码块。
|
||||
|
||||
为了保持代码块的简单,给代码分块的一个重要原则就是,**一个代码块只做一件事情**。前面,我们曾经使用过下面的例子。这个例子中,检查用户名是否符合用户名命名的规范,以及检查用户名是否是注册用户,放在了一个方法里。
|
||||
|
||||
```
|
||||
/**
|
||||
* 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%时间写的代码,还要多,还要好。这个世界真的不是线性的。
|
||||
|
||||
有一句流传的话,说的是“跑得慢,到得早”。这句话不仅适用于健身,还适用于写程序。
|
||||
|
||||
**借助有效的工具**
|
||||
|
||||
我自己最常使用的工具,就是圆珠笔和空白纸。大部分问题,一页纸以内,都可以解决掉。当然,这中间的过程,可能需要一打甚至一包纸。
|
||||
|
||||
一旦问题有点大,圆珠笔和空白纸就不够用了。这时候,我们需要称手的工具,帮助我们记忆和思考。
|
||||
|
||||
现在我最喜欢的工具有思维导图、时序图和问题清单。在拆解问题时,思维导图可以帮助我厘清思路,防止遗漏。时序图可以帮助我理解关键的用例,勾画清楚各个部件之间的联系。而问题清单,可以记录下要解决和已经解决的问题,帮助我记录状态、追踪进度。
|
||||
|
||||
你最顺手的工具是什么?欢迎你分享在留言区,我们一起来学习。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我们主要聊的话题,就是做小事。我们工作生活中,一旦出现两种以上的竞争策略,要记住这个经过实践检验的理念:选择最简单,最直观的解决方案。
|
||||
|
||||
当然,我们遇到的不会总是简单的问题。 如果把复杂的问题、大的问题,拆解成简单的问题、小的问题,我们就能够化繁为简,保持代码的整洁和思路的清晰。
|
||||
|
||||
## 一起来动手
|
||||
|
||||
通常一个用户登录的设计,需要输入用户名和密码。用户名和密码一起传输到服务器进行校验,授权用户登录。但现在有了更先进的设计。用户先输入用户名,用户名通过服务器检验,才能进一步输入密码,然后授权用户登录。
|
||||
|
||||
你愿不愿意分析一下,这种简单的流程变化,带来的收益? 客户端和服务器端的接口代码,大致应该是什么样子的?你使用了什么工具来分析这些问题?
|
||||
|
||||
欢迎你在留言区讨论上面的问题,我们一起来看看这种简单的变化可以带来什么样的好处。
|
||||
|
||||
|
||||
461
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/21 | 怎么设计一个简单又直观的接口?.md
Normal file
461
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/21 | 怎么设计一个简单又直观的接口?.md
Normal file
@@ -0,0 +1,461 @@
|
||||
<audio id="audio" title="21 | 怎么设计一个简单又直观的接口?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/23/67/23da96a6ecb99d30195e38e5a3c39d67.mp3"></audio>
|
||||
|
||||
我们前面聊过接口规范,开放的接口规范是使用者和实现者之间的合约。既然是合约,就要成文、清楚、稳定。合约是好东西,它可以让代码之间的组合有规可依。但同时它也是坏东西,让接口的变更变得困难重重。
|
||||
|
||||
接口设计的困境,大多数来自于接口的稳定性要求。摆脱困境的有效办法不是太多,其中最有效的一个方法就是要**保持接口的简单直观**。那么该怎么设计一个简单直观的接口呢?
|
||||
|
||||
## 从问题开始
|
||||
|
||||
软件接口的设计,要从真实的问题开始。
|
||||
|
||||
一个解决方案,是从需要解决的现实问题开始的。要解决的问题,可以是用户需求,也可以是现实用例。面对要解决的问题,我们要把大问题分解成小问题,把小问题分解成更小的问题,直到呈现在我们眼前的是公认的事实或者是可以轻易验证的问题。
|
||||
|
||||
比如说,是否可以授权一个用户使用某一个在线服务呢?这个问题就可以分解为两个小问题:
|
||||
|
||||
<li>
|
||||
该用户是否为已注册的用户?
|
||||
</li>
|
||||
<li>
|
||||
该用户是否持有正确的密码?
|
||||
</li>
|
||||
|
||||
我们可以使用思维导图来描述这个分解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c9/f1/c9d13e1a67471048a8d42867171351f1.png" alt=""><br>
|
||||
分解问题时,我们要注意分解的问题一定要“相互独立,完全穷尽”(Mutually Exclusive and Collectively Exhaustive)。这就是MECE原则。使用MECE原则,可以帮助我们用最高的条理化和最大的完善度理清思路。
|
||||
|
||||
如何理解这个原则呢?
|
||||
|
||||
先来说一下“相互独立”这个要求。问题分解后,我们要仔细琢磨,是不是每一个小问题都是独立的,都是可以区分的事情。
|
||||
|
||||
我们以上面的分解为例子,仔细看会发现这种划分是有问题的。因为只有已经注册的用户,才会持有正确的密码。而且,只有持有正确密码的用户,才能够被看作是注册用户。这两个小问题之间,存在着依赖关系,就不能算是“相互独立”。
|
||||
|
||||
我们要消除掉这种依赖关系。
|
||||
|
||||
变更后,就需要两个层次的表达。第一个层次问题是,该用户是否为已注册的用户?这个问题,可以进一步分解为两个更小的问题:用户持有的用户名是否已注册? 用户持有的密码是否匹配?
|
||||
|
||||
<li>
|
||||
该用户是否是已注册的用户?
|
||||
a. 用户名是否已注册?
|
||||
b.用户密码是否正确?
|
||||
</li>
|
||||
|
||||
这种描述的思维导图,和上面的相比,已经有了很大的差别。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/19/51509360e2c8103bbd09eb855483da19.png" alt=""><br>
|
||||
除了每一项都要独立之外,我们还要琢磨,是不是把所有能够找到的因素,都找到了?也就是说,我们是否穷尽了所有的内容,做到了“完全穷尽”?
|
||||
|
||||
你可能早已经注意到了上述问题分解的缺陷。如果一个服务,对所有的注册用户开放,上面的分解就是完备的。否则,我们就漏掉了一个重要的内容,不同的注册用户,可以访问的服务可能是不同的。也就是说如果没有访问的权限,那么即使用户名和密码正确也无法访问相关的服务。
|
||||
|
||||
如果我们把漏掉的加上,这个问题的分解可以进一步表示为:
|
||||
|
||||
<li>
|
||||
该用户是否是已注册的用户?
|
||||
a. 用户名是否已注册?
|
||||
b.用户密码是否正确?
|
||||
</li>
|
||||
|
||||
2.该用户是否有访问的权限?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/13/fb829082bcd4be9b6e949e4fb39c2913.png" alt=""><br>
|
||||
完成上述的分解后,对于是否授权用户访问一个服务这个问题,我们就会有一个清晰的思路了。
|
||||
|
||||
**为什么从问题开始?**
|
||||
|
||||
为什么我们要遵循“相互独立,完全穷尽”的原则呢?
|
||||
|
||||
只有完全穷尽,才能把问题解决掉。否则,这个解决方案就是有漏洞的,甚至是无效的。
|
||||
|
||||
只有相互独立,才能让解决方案简单。否则,不同的因素纠缠在一起,既容易导致思维混乱,也容易导致不必要的复杂。
|
||||
|
||||
还有一个问题,我们也要清楚地理解。那就是,为什么要从问题开始呢?
|
||||
|
||||
从问题开始,是为了让我们能够找到一条主线。然后,围绕这条主线,去寻找解决问题的办法,而不是没有目标地让思维发散。这样,也可以**避免需求膨胀和过度设计**。
|
||||
|
||||
比如说,如果没有一条主线牵制着,按照面向对象编程的思路,我们看到“用户”两个字,马上就会有无限的联想。是男的还是女的呀?姓啥名谁呀?多大岁数了?家住哪儿啊?一系列问题都会冒出来,然后演化成一个庞大的对象。但事实上,对于上面的授权访问问题,我们根本不需要知道这些。
|
||||
|
||||
**自然而来的接口**
|
||||
|
||||
把大问题分解成小问题,再把小问题分解成更小的问题。在这个问题逐层分解的过程中,软件的接口以及接口之间的联系,也就自然而然地产生了。这样出来的接口,逻辑直观,职责清晰。对应的,接口的规范也更容易做到简单、稳定。
|
||||
|
||||
还记得我们前面说过的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
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你已经理解了我们前面的问题分解,你就会觉得原来的名字有点儿刺眼或者混乱。这就是问题分解带给我们的好处。问题的层层简化,会让接口的逻辑更直观,职责更清晰。这种好处,也会传承给后续的接口设计。
|
||||
|
||||
## 一个接口一件事情
|
||||
|
||||
前面,我们提到过一行代码只做一件事情,一块代码只做一件事情。一个接口也应该只做一件事情。
|
||||
|
||||
如果一行代码一件事,那么一块代码有七八行,不是也应该做七八件事情吗?怎么能说是一件事情呢?这里我们说的“事情”,其实是在某一个层级上的一个职责。授权用户访问是一件完整、独立的事情;判断一个用户是否已注册也是一件完整、独立的事情。只是这两件事情处于不同的逻辑级别。也就是说,一件事情,也可以分几步完成,每一步也可以是更小的事情。有了逻辑级别,我们才能分解问题,接口之间才能建立联系。
|
||||
|
||||
对于一件事的划分,我们要注意三点。
|
||||
|
||||
<li>
|
||||
一件事就是一件事,不是两件事,也不是三件事。
|
||||
</li>
|
||||
<li>
|
||||
这件事是独立的。
|
||||
</li>
|
||||
<li>
|
||||
这件事是完整的。
|
||||
</li>
|
||||
|
||||
如果做不到这三点,接口的使用就会有麻烦。
|
||||
|
||||
比如下面的这段代码,用于表示在不同的语言环境下,该怎么打招呼。在汉语环境下,我们说“你好”,在英语环境下,我们说“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()这两个方法,都不能表达一个完整的事情。只有两个方法合起来,才能表达一件完整的事情。
|
||||
|
||||
这种互相依赖的关系,会导致很多问题。 比如说:
|
||||
|
||||
<li>
|
||||
使用时,应该先调用哪一个方法?
|
||||
</li>
|
||||
<li>
|
||||
如果语言和问候语不匹配,会出现什么情况?
|
||||
</li>
|
||||
<li>
|
||||
实现时,需不需要匹配语言和问候语?
|
||||
</li>
|
||||
<li>
|
||||
实现时,该怎么匹配语言和问候语?
|
||||
</li>
|
||||
|
||||
这些问题,使用上面示例中的接口设计,都不好解决。 一旦接口公开,软件发布,就更难解决掉了。
|
||||
|
||||
**减少依赖关系**
|
||||
|
||||
有时候,“一个接口一件事情”的要求有点理想化。如果我们的设计不能做到这一点,一定要减少依赖关系,并且声明依赖关系。
|
||||
|
||||
一般来说一个对象,总是先要实例化,然后才能调用它的实例方法。构造方法和实例方法之间,就有依赖关系。这种依赖关系,是规范化的依赖关系,有严格的调用顺序限制。编译器可以帮我们检查这种调用顺序。
|
||||
|
||||
但是,我们自己设计的实例方法之间的依赖关系,就没有这么幸运了。这就要求我们弄清楚依赖关系,标明清楚依赖关系、调用顺序,以及异常行为。
|
||||
|
||||
下面的这段代码,摘录自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例子中,如果三个方法的调用顺序除了差错,接口就不能正常地使用,程序就不能正常地运转。既不方便,也不皮实。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我们主要讨论了该怎么设计简单直观的接口这个话题。这是一个很大的话题。我们只讨论了最基本的原则,那就是:
|
||||
|
||||
<li>
|
||||
从真实问题开始,把大问题逐层分解为“相互独立,完全穷尽”的小问题;
|
||||
</li>
|
||||
<li>
|
||||
问题的分解过程,对应的就是软件的接口以及接口之间的联系;
|
||||
</li>
|
||||
<li>
|
||||
一个接口,应该只做一件事情。如果做不到,接口间的依赖关系要描述清楚。
|
||||
</li>
|
||||
|
||||
另外,关于面向对象设计,有一个简称为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
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
300
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/22丨高效率,从超越线程同步开始!.md
Normal file
300
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/22丨高效率,从超越线程同步开始!.md
Normal file
@@ -0,0 +1,300 @@
|
||||
<audio id="audio" title="22丨高效率,从超越线程同步开始!" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/47/3f022b44602801f085e5ebc472c6ee47.mp3"></audio>
|
||||
|
||||
线程的同步是学习一门编程语言的难点。刚开始线程同步的困难,主要在于了解技术;跨过了基本技术的门槛后,更难的是掌握最基本的概念。
|
||||
|
||||
学习技术时,我们对基本概念熟视无睹,只想将宝剑尽快握在手,哪管宝剑何时该挥出的教导。学会技术后,基本概念就会回来找我们算旧账,出错一次剑,就记一笔账。账本慢慢变厚的过程,也是我们向基本概念靠拢的过程。当我们掌握了最基本的概念后,开始慢慢还账,账本再越变越薄。
|
||||
|
||||
不单单是线程和同步,掌握好基本概念,几乎是我们学习所有技术背后的困境。这怨不得我们自己,我们认识一件事情的过程,大抵就是这样。
|
||||
|
||||
如果有人很早地就敲着桌子,不厌其烦地重复着基本的概念,事情会不会容易一些?这一次,我们聊聊线程同步的基本概念,以及如何超越线程同步。
|
||||
|
||||
## 什么时候需要同步?
|
||||
|
||||
线程有两个重要的特征,就是并发执行和共享进程资源。
|
||||
|
||||
你可以把进程想象成一个鱼缸。鱼缸里的金鱼可以看作线程。鱼缸里的碎石、水草、鱼食等可以看作共享的资源。每一条鱼都独立行动,随时可以吐个气泡,吃点鱼食,耍弄下水草。
|
||||
|
||||
鱼缸里的碎石、水草,小鱼儿搬不走、吃不掉,是一个不变的量。鱼食和气泡就不一样了,每一条小鱼儿随时都会吐泡泡、吃鱼食,改变气泡和鱼食的数量。鱼食和气泡,是鱼缸里的可变量。
|
||||
|
||||
如果有一条小鱼儿,想要数数有多少气泡,麻烦就来了,小鱼儿要吐出新泡泡,水面的旧泡泡要破掉,怎么数都跟不上变化的节奏。怎么办呢?要让变化停止,数清楚之前,其他的小鱼儿不能吐新泡泡,水面的泡泡也不能破掉。数清楚后,再恢复行动。**这就像是线程的同步**。
|
||||
|
||||
线程的并发执行和共享进程资源,是为了提高效率。可是线程间如何管理共享资源的变化,却是一个棘手的问题,甚至是一个损害效率的问题。如果有两个以上的线程,关心共享资源的变化,一旦共享资源发生变化,就需要同步。线程同步的意思,就是一个接一个来,上一个线程没有完成一项任务之前,下一个线程不能开始相关的行动。简单地说,就是排队。
|
||||
|
||||
什么时候需要同步呢?需要同步的场景,要同时满足三个条件:
|
||||
|
||||
<li>
|
||||
使用两个以上的线程;
|
||||
</li>
|
||||
<li>
|
||||
关心共享资源的变化;
|
||||
</li>
|
||||
<li>
|
||||
改变共享资源的行为。
|
||||
</li>
|
||||
|
||||
## 同步是损害效率的
|
||||
|
||||
假设一条小鱼吐一个泡泡1秒钟,如果没什么限制,10条小鱼1秒钟就可以吐10个泡泡。可是,如果要小鱼排队吐泡泡,10条小鱼1秒钟最多只能吐1个泡泡,这还没算上小鱼儿交接的时间。实际上,10条排队的小鱼1秒钟可能只能吐0.9个泡泡,因为交接也是要费时间的。
|
||||
|
||||
线程同步也是这样的,同步需要排队,同步的管理需要时间。所以,实践中,我们要想尽办法避免线程的同步。如果实在难以避免,就减少线程同步的排队时间。
|
||||
|
||||
## 避免线程同步
|
||||
|
||||
该怎么避免线程同步呢?
|
||||
|
||||
对应上述的同步场景所需的三个条件,我们只要打破其中的任何一个条件,就不需要线程同步了:
|
||||
|
||||
<li>
|
||||
使用单线程;
|
||||
</li>
|
||||
<li>
|
||||
不关心共享资源的变化;
|
||||
</li>
|
||||
<li>
|
||||
没有改变共享资源的行为。
|
||||
</li>
|
||||
|
||||
举个例子吧,下面的这段代码用于表示在不同的语言环境下,该怎么打招呼。在汉语环境下,我们说“你好”;在英语环境下,我们说"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 ;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
比如说,如果两个线程的执行顺序是:
|
||||
|
||||
<li>
|
||||
线程1执行getLanguage(),得到返回值是英语的语言环境;
|
||||
</li>
|
||||
<li>
|
||||
线程2执行setGreeting(),把问候语设置为汉语环境的“你好”;
|
||||
</li>
|
||||
<li>
|
||||
线程1执行getGreeting(),得到返回值是问候语“你好”。
|
||||
</li>
|
||||
|
||||
那么,按照线程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秒。
|
||||
|
||||
在阻塞的这段时间里,做的事情越少,阻塞时间一般就会越短。
|
||||
|
||||
这个小鱼吐泡泡的过程,可以表示成如下的代码:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/f4/70845e3080dc14621fef4e9b56d416f4.png" alt=""><br>
|
||||
从这段代码里,我们可以看到,减少阻塞时间的一个办法,就是**只同步和共享资源变化相关的逻辑**。引起共享资源变化的事前准备以及善后处理,属于线程内部变化,不需要同步处理。
|
||||
|
||||
在设计接口或者实现代码时,有一项很重要的一个工作,就是反复考虑在多线程环境下,怎么做才能让线程同步的阻塞时间最小。这是一个很值得花费时间去琢磨的地方。比如上面小鱼吐泡泡的微小改进,效率就提高了三倍。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我们主要讨论线程同步的基本概念以及超越线程同步的技巧。由于线程同步对效率的损害,我们使用线程同步的最高技巧,就是不使用线程同步。如果做不到这一点,在线程同步的处理时间内,做的事情越少越好。
|
||||
|
||||
线程同步本身非常复杂,它相关的技术也很繁杂。这方面可以参考的书籍和文章也很多。我们不在这里讨论这些同步的技术了。
|
||||
|
||||
欢迎你在留言区,讨论这些技术,分享你使用这些技术的心得体会,我们一起来学习、精进。
|
||||
|
||||
## 一起来动手
|
||||
|
||||
下面的这段代码,摘录自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
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
137
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/23 | 怎么减少内存使用,减轻内存管理负担?.md
Normal file
137
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/23 | 怎么减少内存使用,减轻内存管理负担?.md
Normal file
@@ -0,0 +1,137 @@
|
||||
<audio id="audio" title="23 | 怎么减少内存使用,减轻内存管理负担?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/77/b3/7766ac3c97ad38a8e6beb71d9af850b3.mp3"></audio>
|
||||
|
||||
管理内存,不管是什么编程语言,向来都是一个难题。Java语言能够长期领先的一个重要原因,就是它拥有强大的内存管理能力,并且这种能力还在不断地进化。然而,只依靠Java内在的内存管理能力,是远远不够的。
|
||||
|
||||
2018年9月,亚马逊向OpenJDK社区提交了一个改进请求。这个改进涉及到一个问题,如果一个服务的缓存数量巨大,比如说有10万个连接会话,Java的垃圾处理器要停滞几分钟,才能清理完这么巨大的缓存。而这几分钟的停滞,是不可忍受的事故。
|
||||
|
||||
这是一个值得我们关注的细节。缓存的本意,就是为了提高效率。然而,拥有过多的用户,过多的缓存,反而会让效率变低。
|
||||
|
||||
随着大数据、云计算以及物联网的不断演进,很多技术都面临着巨大的挑战。七八年前(2010年左右),能解决C10K(同时处理1万个用户连接)问题,感觉就可以高枕无忧了。现在有不少应用,需要开始考虑C10M(同时处理1千万个用户连接)问题,甚至是更多的用户连接,以便满足用户需求。很多以前不用担心的问题,也会冒出来算旧账。
|
||||
|
||||
要想让内存使用得更有效率,我们还需要掌握一些成熟的实践经验。
|
||||
|
||||
## 使用更少的内存
|
||||
|
||||
提高内存使用最有效率的办法,就是使用更少的内存。这听起来像是废话,却也是最简单直接、最有用的办法。减少内存的使用,意味着更少的内存分配、更少的内存填充、更少的内存释放、更轻量的垃圾回收。内存的使用减少一倍,代码的效率会成倍地提升,这不是简单的线性关系。
|
||||
|
||||
减少内存的使用,办法有且只有两个。第一个办法是减少实例的数量。第二个办法是减小实例的尺寸。
|
||||
|
||||
## 减少实例数量
|
||||
|
||||
还记得以前我们用到的,在不同的语言环境下,该怎么打招呼的代码吗?上一次,我们把它改成了不可变的类,避免了线程同步的问题。我把这段代码重新抄录在下面。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7e/ea/7ef80d04c5d8da0aeac59bafac557dea.png" alt=""><br>
|
||||
这段代码还有个问题,就是内存使用不够友好。对于汉语环境来说,打招呼用“你好”。如果使用上面的设计,那么每次使用汉语环境,调用构造方法,都产生一个不同的实例对象。
|
||||
|
||||
如果只实例化一次,当然没有什么问题。如果要实例化10次,100次,1000次,10000次,而且每个实例都是固定的汉语加“你好”,这就是很大的浪费了。内存的使用,随着实例的数量线性增长,100个实例,就要使用100倍的内存。即便实例的产生和废弃都非常迅速,在巨大的实例数量面前,Java的垃圾处理器也会有很大的压力。
|
||||
|
||||
```
|
||||
HelloWords helloWords = new HelloWords("Chinese", "Ni Hao");
|
||||
......
|
||||
|
||||
System.out.prinyln(
|
||||
"The hello words in Chinese: " + helloWords.getGreeting());
|
||||
|
||||
```
|
||||
|
||||
如果一种语言环境的打招呼的办法是固定不变的,而且语言环境的数量有限的话,我们就只有必要使用一个实例。
|
||||
|
||||
如果有了这个意识的话,那么对于这个打招呼的代码,我们就可以很自然地想到使用枚举类型,把它改进成下面的样子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/27/d9230afaccf1c0e6d34b5400e35ffa27.png" alt=""><br>
|
||||
使用了枚举类型后,每一种语言就只有一个实例了。不管使用多少次,对内存的影响,对Java的垃圾处理器的影响,几乎可以忽略不计。
|
||||
|
||||
对于数量有限的对象,我们应该优先考虑使用枚举类型,比如交通标志,国家名称等等。其实,枚举类型就是一种常用的数据静态化的范例。我们还会在后面讨论其他类似的数据静态化的处理方式。
|
||||
|
||||
### 避免不必要的实例
|
||||
|
||||
Java语言里,有一些历史遗留的接口设计问题,会无意中导致不必要的实例。我们下面来看看两个例子。
|
||||
|
||||
第一个例子是应用程序使用了不必要的构造函数。比如,使用String的构造函数实例化一串字符。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/0d/6a4396ae788969f3ce2d709f22d8cc0d.png" alt=""><br>
|
||||
上面的反面实例,每次调用都会产生一个实例对象,而这个实例对象需要交给Java垃圾处理器管理。事实上,由于String是一个不可变的类,每次调用产生的实例没有任何的区别。如果这样的代码经常使用,比如说被调用了十万次,就会有十万个实例对象产生,Java垃圾处理器就需要管理十万个实例。
|
||||
|
||||
这是一个很大的,不必要的开销。上面的两个正面案例,使用单实例的编码习惯,无论这段代码被调用了多少次,在Java的运行环境下,都只有一个实例。而且,相同的字符串,即使位于不同的代码空间里,在同一Java的运行环境下,也都只有一个实例。
|
||||
|
||||
String类的这个构造函数,是一个接口设计的历史遗留问题,价值小,问题多。Java基础类库里,还有一些类似的历史遗留问题,特别是原始数据类型(primitive type)对应的类。我们要避免使用它们的构造方法,甚至避免使用这些类。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/d0/f111895c74316514bdcd0f019fcaa1d0.png" alt=""><br>
|
||||
幸运的是,这些原始数据类型对应类的构造方法,从Java 9开始,就已经被废弃了。但是这些方法依然存在,这些类依然存在。不论在哪里,如果你看到还有代码使用原始数据类型的构造函数,都可以提交一个问题报告。这样的更改,付出少,收益大。
|
||||
|
||||
### 避免使用原始数据类
|
||||
|
||||
通过上面的讨论,我们可以理解,为什么要避免使用原始数据类型的构造方法。可是为什么还要避免使用原始数据类呢?这里涉及到Java原始数据类型的自动装箱(boxing)与拆箱(unboxing)的类型转换。
|
||||
|
||||
比如说,下面的代码,就涉及到一个装箱的过程。整数0和2都要先被转换成一个Long类的实例,然后才执行赋值操作。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/46/2bcb06d24ede01daaa2b323114748d46.png" alt=""><br>
|
||||
这个装箱的过程,就产生了不必要的实例。如果这样的转换数量巨大,就会有明显的性能影响。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c5/71/c5d0d51a6643b9afce5d18d990dab871.png" alt="">
|
||||
|
||||
### 使用单实例模式
|
||||
|
||||
由于Java内在的单实例模式,我们可以很方便地使用Java的原始数据类型,而不用担心实例数量的增长。对于复合的类,我们也可以自己设计单实例模式,从而减少多实例带来的不必要的开销。
|
||||
|
||||
比如,下面的代码,就是一个单实例模式例子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/8e/3a8261b250cac596481e5661a244818e.png" alt=""><br>
|
||||
单实例的设计方法有很多种方式,也有很多小细节需要处理,限于篇幅,我们就不在这里讨论这些技术了。欢迎你在讨论区分享你的经验和想法,来丰富这一部分的内容。
|
||||
|
||||
## 减小实例的尺寸
|
||||
|
||||
减少内存的使用还有另外一个办法,就是减小实例的尺寸。所谓减少实例的尺寸,就是减少这个实例占用的内存空间。这个空间,不仅包括实例的变量标识符占用的空间,还包括标识符所包含对象的占用空间。
|
||||
|
||||
比如下面的例子中,使用了String构造方法的变量,就独占了包括“Java”这四个字符的String实例空间。而使用了字符串赋值的变量,就和其他代码一起共享“Java”这四个字符的缺省的实例空间。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1f/68/1f202ccac93835e7ca0adae9f47cf468.png" alt=""><br>
|
||||
在减少变量数量这一方面,我们一般没有太多的自由空间。那么,在减少实例尺寸方面,我们能有所作为的,就是在标识符所指对象方面多费心思。简单地说,就是减少标识符所引用对象的尺寸。办法也有两个,第一个是尽量减少独占的空间;第二个是尽量使用共享的实例。
|
||||
|
||||
尽可能多地共享资源,这是一条提高效率的基本原则。在编写代码时,如果能够引用,就坚决不要拷贝;如果能够复用,就坚决不要新创。当然,资源的共享,除了上一次提到的线程同步问题,还有一个资源的维护问题。一个资源,如果不需要维护,那就太理想了。
|
||||
|
||||
有两类理想的共享资源,一类是一成不变(immutable)的资源,另一类是禁止修改(unmodifiable)的资源。
|
||||
|
||||
## 不可变的类
|
||||
|
||||
上一次,在讨论线程同步问题时,我们也讨论了不可变的类。由于不可变的类一旦实例化,就不再变化,我们可以放心地在不同的地方使用它的引用,而不用担心任何状态变化的问题。
|
||||
|
||||
## 无法修改的对象
|
||||
|
||||
还有一类对象,虽然不是不可变的类的实例,但是它的修改方法被禁止了。当我们使用这些对象的代码时,没有办法对它做出任何修改。这样,这些对象就有了和不可变的实例一样的优点,可以放心地引用。
|
||||
|
||||
从Java 8开始,Java核心类库通过Collections类提供了一系列的生成不可更改的集合的方法。这些方法,极大地减轻了集合的共享和维护问题。
|
||||
|
||||
比如,下面的这个方法,就返回了一个不可更改的列表对象。这个对象,可以赋值给多个标识符,不需要列表的拷贝,也不用担心列表的维护问题。在合适的场景,考虑使用好不可更改的集合,是一个值得推荐的编码习惯。
|
||||
|
||||
```
|
||||
public List<byte[]> getStatusResponses() {
|
||||
List<byte[]> responses = new ArrayList<>();
|
||||
// snipped
|
||||
|
||||
return Collections.unmodifiableList(responses);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
毋庸置疑的是,我们不能总是使用不变的共享资源。可以变化的共享资源也有难以替代的作用。后面的章节,我们再接着讨论使用可变的共享资源的技巧。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我们主要讨论了怎么减少内存使用。基本的方向有两个,一个是减少实例数量,另一个是减少实例的尺寸。这两个方向看着都很简单,我们在编码时,要养成考虑这两个因素的习惯。想得多了,用得多了,你编写的代码对内存就会越来越友好,设计的接口也会越来越好用。
|
||||
|
||||
应用程序方面,内存使用的优化技术和实践有很多。欢迎你在留言区,讨论这些技术和经验,分享你使用这些技术的心得体会,我们一起来学习、精进。
|
||||
|
||||
## 一起来动手
|
||||
|
||||
我上面使用的一个例子,写得确实很丑陋。不过,当我想到,可以把它当作一个练手题的时候,我就稍微宽心了点。
|
||||
|
||||
你琢磨琢磨下面的这段代码,看看能不能实现getInstance()这个方法。该怎么修改,才能让这个方法更有效率?
|
||||
|
||||
另外,你能想明白为什么构造方法会设计成私有方法吗?变量为什么没有使用private关键字?这些小细节很有意思,如果你已经清楚了这些细节背后的原因,也欢迎你分享在讨论区。
|
||||
|
||||
欢迎你在留言区讨论上面的问题,也可以把这篇文章分享给你的朋友或者同事,我们一起来看看这个有点丑的代码,可以变得有多美。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/9e/3d160600ffe29fdb5fd1b6df9463469e.png" alt="">
|
||||
|
||||
|
||||
210
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/24 | 黑白灰,理解延迟分配的两面性.md
Normal file
210
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/24 | 黑白灰,理解延迟分配的两面性.md
Normal file
@@ -0,0 +1,210 @@
|
||||
<audio id="audio" title="24 | 黑白灰,理解延迟分配的两面性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/90/50/9017744b7d932b1312dca1807c8a1e50.mp3"></audio>
|
||||
|
||||
上一次,我们讨论了减少内存使用的两个大方向,减少实例数量和减少实例的尺寸。如果我们把时间的因素考虑在内,还有一些重要的技术,可以用来减少运行时的实例数量。其中,延迟分配是一个重要的思路。
|
||||
|
||||
## 延迟分配
|
||||
|
||||
在前面讨论怎么写声明的时候,为了避免初始化的遗漏或者不必要的代码重复,我们一般建议“声明时就初始化”。但是,如果初始化涉及的计算量比较大,占用的资源比较多或者占用的时间比较长,声明时就初始化的方案可能会占用不必要的资源,甚至成为软件的一个潜在安全问题。
|
||||
|
||||
这时候,我们就需要考虑延迟分配的方案了。也就是说,不到需要时候,不占用不必要的资源。
|
||||
|
||||
下面,我们通过一个例子来了解下什么是延迟分配,以及延迟分配的好处。
|
||||
|
||||
在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的上面的改动,我们能够学习到什么东西呢?我学到的最重要的东西是,对于使用频率高的类的实现,微小的性能改进,都可以带来巨大的实用价值。
|
||||
|
||||
在前面讨论[怎么写声明](https://time.geekbang.org/column/article/78288)的时候,我们讨论到了“**局部变量需要时再声明**”这条原则。局部变量标识符的声明应该和它的使用尽可能地靠近。这样的规范,除了阅读方面的便利之外,还有效率方面的考虑。局部变量占用的资源,也应该需要时再分配,资源的分配和它的使用也要尽可能地靠近。
|
||||
|
||||
## 延迟初始化
|
||||
|
||||
延迟分配的思路,就是用到声明时再初始化,这就是延迟初始化。换句话说,不到需要的时候,就不进行初始化。
|
||||
|
||||
下面的这个例子,是我们经常使用的初始化方案,声明时就初始化。
|
||||
|
||||
```
|
||||
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初学者来说,理解这段代码可能需要费点功夫。评审代码的时候,每次遇到这个模式,我都要小心再小心,谨慎再谨慎,生怕漏掉了某个细节。
|
||||
|
||||
借着这个机会,我们一起来把这个模式理解透,搞清楚这段代码里每一个变量、每一个关键词扮演的角色。以后遇到它,我们也许可以和它把手言欢。
|
||||
|
||||
我把这段代码重新抄写在了下面,关键的地方加了颜色。我们在讨论区讨论下面这些问题:
|
||||
|
||||
<li>
|
||||
helloWordsMap变量为什么使用volatile限定词?
|
||||
</li>
|
||||
<li>
|
||||
为什么要temporaryMap变量?
|
||||
</li>
|
||||
<li>
|
||||
temporaryMap变量为什么要两次设置为helloWordsMap?
|
||||
</li>
|
||||
<li>
|
||||
为什么要检查两次temporaryMap的值不等于空?
|
||||
</li>
|
||||
<li>
|
||||
synchronized为什么用在第一次检查之后?
|
||||
</li>
|
||||
<li>
|
||||
为什么使用ConcurrentHashMap而不是HashMap?
|
||||
</li>
|
||||
<li>
|
||||
为什么使用temporaryMap.put()而不是helloWordsMap.put()?
|
||||
</li>
|
||||
|
||||
如果你有更多的问题,请公布在讨论区,也可以和你的朋友一起讨论。弄清楚了这些问题,我相信我们可以对Java语言的理解更深入一步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/1b/b9f09ce12aea369f0e4959a74d9e4b1b.png" alt="">
|
||||
|
||||
|
||||
180
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/25 | 使用有序的代码,调动异步的事件.md
Normal file
180
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/25 | 使用有序的代码,调动异步的事件.md
Normal file
@@ -0,0 +1,180 @@
|
||||
<audio id="audio" title="25 | 使用有序的代码,调动异步的事件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cb/60/cb0638264303f025988f38cd6ab23d60.mp3"></audio>
|
||||
|
||||
同步和异步,是两个差距很大的编程模型。同步,就是很多事情一步一步地做,做完上一件,才能做下一件。异步,就是做事情不需要一步一步的,多件事情,可以独立地做。
|
||||
|
||||
比如一个有小鸟的笼子,如果打开笼门,一个一个地放飞小鸟,就是同步。如果拆了整个鸟笼,让小鸟随便飞,爱怎么飞就怎么飞,这就是异步。
|
||||
|
||||
## 为什么需要异步编程?
|
||||
|
||||
如果我们观察身边的事物,现实中有很多事情是以异步的方式运营的。我们挤地铁的时候,从来都是好几个人一起挤进去的。当我们正在挤地铁时,外面的风照旧吹,雨照旧下,天坛的大爷大妈们正在秀着各种绝活。没有任何事情会因为我们正在挤地铁就停止活动,等我们挤完地铁再恢复运转。
|
||||
|
||||
可是,要是说到其中的任何一个人,就不能同时做两件事情了。在挤地铁的时候,就不能在天坛秀绝活。我们写的程序也是这样,先执行第一行,再执行第二行。哪怕第二行再怎么费周折,第三行代码也要等着。
|
||||
|
||||
第二行代码可能需要执行大量的计算,需要很多的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,它还有提升的空间。
|
||||
|
||||
前面,我们讨论了减少[内存使用的两个大方向](https://time.geekbang.org/column/article/83045),减少实例数量和减少实例的尺寸。使用共享内存,减少内存拷贝,甚至是零拷贝,可以减少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请求数据原封不动发回去。
|
||||
|
||||
下一篇文章,我会介绍一个简单的测试代码性能的工具。如果有兴趣,你可以继续测试下你编写的代码的性能,是不是比同步的编程模式有所提高。
|
||||
|
||||
欢迎你把你的代码公布在讨论区,我们一起来学习,一起来进步。如果你想和朋友或者同事比试一下,不妨把这篇文章分享给他们,互相切磋。
|
||||
|
||||
|
||||
567
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/26 | 有哪些招惹麻烦的性能陷阱?.md
Normal file
567
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/26 | 有哪些招惹麻烦的性能陷阱?.md
Normal file
@@ -0,0 +1,567 @@
|
||||
<audio id="audio" title="26 | 有哪些招惹麻烦的性能陷阱?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ef/a7/ef356b1b8a3237a9283b8249b360dda7.mp3"></audio>
|
||||
|
||||
前面,我们讨论了改善代码性能的最基本的办法。接下来,我们讨论一些最佳实践,让我们先从一些容易被忽略的性能陷阱开始。
|
||||
|
||||
## 使用性能测试工具
|
||||
|
||||
今天我们的讲解需要用到一个工具,它就是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慢的原因之一。
|
||||
|
||||
通过上面的基准测试,我们可以得出这样的结论:
|
||||
|
||||
<li>
|
||||
频繁的对象创建、销毁,有损代码的效率;
|
||||
</li>
|
||||
<li>
|
||||
减少内存分配、拷贝、释放的频率,可以提高代码的效率;
|
||||
</li>
|
||||
<li>
|
||||
即使是单线程环境,使用线程同步依然有损代码的效率。
|
||||
</li>
|
||||
|
||||
从上面的基准测试结果,是不是可以得出结论,我们应该使用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!";
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过上面的基准测试,我们可以总结出下面的几条最佳实践:
|
||||
|
||||
<li>
|
||||
Java的编译器会优化常量字符串的连接,我们可以放心地把长的字符串换成多行;
|
||||
</li>
|
||||
<li>
|
||||
带有变量的字符串连接,StringBuilder效率更高。如果效率敏感的代码,建议使用StringBuilder。String的连接操作可读性更高,效率不敏感的代码可以使用,比如异常信息、调试日志、使用不频繁的代码;
|
||||
</li>
|
||||
<li>
|
||||
如果涉及大量的字符串操作,使用StringBuilder效率更高;
|
||||
</li>
|
||||
<li>
|
||||
除非有线程安全的需求,不推荐使用线程安全的StringBuffer。
|
||||
</li>
|
||||
|
||||
## 内存的泄露
|
||||
|
||||
内存泄漏是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语句。要不然,关闭资源的代码可能很复杂。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/53/c8f705647492b0faccbfff026eb88d53.png" alt=""><br>
|
||||
如果一个类需要关闭,但是又没有实现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()这两个方法。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/82/bf4fd2108a994b6bbfa7845dc65b1d82.jpg" alt="">
|
||||
|
||||
## 撞车的哈希值
|
||||
|
||||
实现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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
另外,你也可以检查一下你手头的代码,看看有没有踩到类似的坑。如果遇到类似的陷阱,看一看能不能改进。
|
||||
|
||||
容易被忽略的性能陷阱,有很多种。这些大大小小的经验,需要我们日复一日的积累。如果你有这方面的经验,或者看到这方面的技术,请你分享在留言区,我们一起来学习、积累这些经验。
|
||||
|
||||
也欢迎点击“请朋友读”,把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
104
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/27 | 怎么编写可持续发展的代码?.md
Normal file
104
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/27 | 怎么编写可持续发展的代码?.md
Normal file
@@ -0,0 +1,104 @@
|
||||
<audio id="audio" title="27 | 怎么编写可持续发展的代码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/79/60/791e7eac01d57c82d35e2d8e93186c60.mp3"></audio>
|
||||
|
||||
成功的大公司,也是从小公司起步的。刚开始的时候,软件可能比较简单,用户也比较少,一台廉价的服务器,或者一个简单的虚拟机,甚至几个静态的页面就绰绰有余。
|
||||
|
||||
很快,辛苦的努力得到回报,产品传播速度远超预期,用户很喜欢公司的产品或者服务,数量大幅增加,需求越来越强劲。这时候也是公司最忙碌的时候,每个人眼里只有两个字:“增长”。用户规模增长随之带来的是软件规模增长,运维复杂度增长。这时候,廉价的服务器满足不了需求了,就需要更多的服务器,甚至是各种用途不一样的服务器,还需要使用更多的带宽、更多的内存、更多的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文件。如果用户访问的两个不同网站,使用了相同的脚本文件。浏览器只需要下载一次脚本文件,就可以在两个网站使用。这样,缓存的脚本文件就可以加速网页的加载,减轻服务器的压力。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/8d/fd94ae22ae2751b87d8e0ca81d2bcf8d.png" alt="">
|
||||
|
||||
## 分离无状态数据
|
||||
|
||||
由于无状态数据有这么多的优点,把无状态数据分离出来,单独提供服务就成了一个常用的解决方案。独立的无状态数据服务,既没有规模水平扩张的羁绊,还能充分利用客户端的缓存。另外,无状态数据和状态数据的分离,可以让状态数据的处理集中资源,也能提高状态数据的处理能力。
|
||||
|
||||
比如说,一个网站如果使用了共享的jquery.js脚本,下载这个脚本,就不再占用这个网站的资源了。
|
||||
|
||||
所以,如果你要设计一个具有规模扩张能力的软件架构,分离无状态数据和状态数据,既可以提高规模水平能力,也可以提高规模垂直扩张能力。
|
||||
|
||||
## 使用用户资源
|
||||
|
||||
对静态数据进行缓存,是现代浏览器提高服务性能的一个重要办法。除此之外,浏览器还可以缓存动态数据,比如HTTP的cookie以及HTTPS的安全连接的参数。
|
||||
|
||||
鉴于无状态数据的诸多优点,一些协议设计开始考虑无状态的服务场景。比如,TLS 1.3就加入了对无状态服务的支持。
|
||||
|
||||
无状态服务,并不一定都没有服务状态。一个典型的做法是,服务端通过一定的数据保护机制,把服务状态保护起来,发送到客户端。然后,客户端缓存封印的服务状态。下次连接时,客户端把封印的服务状态原封不动地送回到服务端。然后,服务端解封客户端发送的封印服务状态,就获得了需要处理的状态数据。这样,既有了无状态服务的便利,解除了规模水平扩张的限制,又解决了服务需要状态的客观需求。
|
||||
|
||||
遗憾的是,这种设计能够提供的服务状态数据尺寸比较有限,应用场景也比较苛刻,而且数据保护机制一般也比较复杂。所以,我们一般要在基础架构层面解决掉核心的问题(数据保护机制、服务状态封存机制、缓存机制等)。然后,在应用层面仅仅定制状态的内容,比如HTTP的cookie的格式和数据,就是可以定制的内容。而HTTP的cookie交换的机制,就由HTTP协议负责解决。
|
||||
|
||||
## 小结
|
||||
|
||||
如果把我们上面讨论的放到一起,就可以得到具有规模扩张能力的软件的一些最佳实践:
|
||||
|
||||
<li>
|
||||
把无状态数据分离出来,单独提供无状态服务;
|
||||
</li>
|
||||
<li>
|
||||
把最基本的服务状态封装起来,利用客户端的缓存,实现无状态服务;
|
||||
</li>
|
||||
<li>
|
||||
小心使用服务状态,编码时要考虑服务状态的规模水平扩张能力。
|
||||
</li>
|
||||
|
||||
基于上述的原则,市场上有很多优秀的解决方案和成熟技术。欢迎你在留言区分享、讨论这些解决方案和技术。
|
||||
|
||||
## 一起来动手
|
||||
|
||||
这一次的练手题,我们要拆解一下Web页面。找一个你常用的Web服务,比如说InfoQ或者极客时间。使用浏览器的插件,阅读这个HTML页面,试着分析下,这个页面里哪些可能是动态数据,哪些可能是静态数据?这个页面是怎么处理这些数据的?使用我们今天讨论的基本原则,这个页面还有没有优化的空间?如果你侧重于服务端的编码,你想想服务器端该做什么样的调整?
|
||||
|
||||
欢迎你在留言区留言,分享你的看法。也欢迎点击“请朋友读”,把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
223
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/28 | 怎么尽量“不写”代码?.md
Normal file
223
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/28 | 怎么尽量“不写”代码?.md
Normal file
@@ -0,0 +1,223 @@
|
||||
<audio id="audio" title="28 | 怎么尽量“不写”代码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/18/b6/183bc06df6fe89db636584c9282fe6b6.mp3"></audio>
|
||||
|
||||
最有效率的编码就是少编写代码,甚至不编写代码。前面,我们讨论过避免需求膨胀和设计过度,就是减少编码的办法之一。这一次,我们讨论代码复用的问题。商业的规模依赖于可复制性,代码的质量依赖于可复用性。
|
||||
|
||||
比如,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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个修改仅仅调换了一下两个方法的调用顺序。根据这两个方法的接口规范,调用顺序的修改不应该出现任何问题。然而,让人意向不到的是,这个接口的实现者,大都依赖于严格的调用顺序。修改前的调用顺序,已经使用了十多年了,大家都习以为常,认为严格的调用顺序依赖并没有问题。一旦改变了这个调用顺序,很多应用程序就不能正常工作了,就会出现严重的兼容性问题。
|
||||
|
||||
我们每个人都会写很多烂代码,过去写过,未来可能还会再写。这些烂代码,如果运行得很好,没有出现明显的问题,我们就放手吧。
|
||||
|
||||
但不是说烂代码我们就永远不管不问了。那么,什么时候修改烂代码呢?代码投入使用之前,以及代码出问题的时候,就是我们修改烂代码的时候。
|
||||
|
||||
那么代码的修改都有哪些需要注意的地方呢?
|
||||
|
||||
代码规范方面的修改,可以大胆些。比如命名规范、代码整理,这些都动不了代码的逻辑,是安全的修改。
|
||||
|
||||
代码结构方面的修改,则要谨慎些,不要伤及代码的逻辑。比如把嵌套太多的循环拆分成多个方法,把几百行的代码,拆分成不同的方法,把相似的代码抽象成复用的方法,这些也是相对安全的修改。
|
||||
|
||||
代码逻辑方面的修改,要特别小心,除了有明显的问题,我们都尽量避免修改代码的逻辑。即使像上面例子中那样的微小的调用顺序的改变,都可能有意想不到的问题。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我们聊了代码复用的一些基本概念。关键的有三点:
|
||||
|
||||
<li>
|
||||
要提高代码的复用比例,减少编码的绝对数量;
|
||||
</li>
|
||||
<li>
|
||||
要复用外部的优质接口,并且推动它们的改进;
|
||||
</li>
|
||||
<li>
|
||||
烂代码该放手时就放手,以免引起不必要的兼容问题。
|
||||
</li>
|
||||
|
||||
## 一起来动手
|
||||
|
||||
今天的练手题,我们来分析下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)里加一个新方法?
|
||||
|
||||
欢迎你在留言区讨论上面的问题,我们一起来了解很多接口设计背后的妥协,以及接口演进的办法。也欢迎点击“请朋友读”,把这篇文章分享给你的朋友或者同事,一起交流一下。
|
||||
|
||||
|
||||
235
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/29 | 编写经济代码的检查清单.md
Normal file
235
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/29 | 编写经济代码的检查清单.md
Normal file
@@ -0,0 +1,235 @@
|
||||
<audio id="audio" title="29 | 编写经济代码的检查清单" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1d/a3/1d3eb00043afcc10ddc1fca236fa32a3.mp3"></audio>
|
||||
|
||||
通过前面十几讲的学习,我们已经把代码“经济”篇的内容学习完了。今天,我们一起把前面讨论到的观点总结一下,并探索一下编写经济代码时的最佳实践检查清单。
|
||||
|
||||
## 为什么需要经济的代码?
|
||||
|
||||
我在[经济篇这一模块开始的时候](https://time.geekbang.org/column/article/81394)讲过这个问题,这里再来简单回忆一遍。
|
||||
|
||||
**1.提升用户体验**
|
||||
|
||||
一致性的性能体验,是软件产品赢得竞争的关键指标。复杂的,反应迟钝的软件,很难赢得用户的尊敬。
|
||||
|
||||
**2.降低研发成本**
|
||||
|
||||
通过降低软件的复杂度,提高软件的复用,提前考虑性能问题,可以降低软件研发成本,缩短软件开发周期。
|
||||
|
||||
**3.降低运营成本**
|
||||
|
||||
经济的代码可以降低软件的复杂度,提高计算资源的使用效率,降低运营成本。
|
||||
|
||||
**4.防范可用性攻击**
|
||||
|
||||
复杂的代码和性能低下的代码,更容易成为黑客攻击的目标。如果一个服务器,需要耗费很多资源才能处理一个请求,那么数量很少的模拟请求攻击,就可以导致服务器瘫痪。
|
||||
|
||||
## 怎么编写经济的代码?
|
||||
|
||||
既然我们都知道编写经济代码的重要性,那么如何让自己的代码经济又高效呢?
|
||||
|
||||
在前面的文章中,我给你从避免过度设计、选择简单直观、超越线程同步、减少内存使用、避免性能陷阱、规模扩张能力等角度探讨了一些方法,下面我提炼了几个点,我们再来重新温习一遍。
|
||||
|
||||
**1.避免过度设计**
|
||||
|
||||
我们从[需求和设计两个角度](https://time.geekbang.org/column/article/81899)探讨了代码的经济问题。
|
||||
|
||||
避免需求膨胀的方式主要有两个,第一个是识别核心需求,我们要从用户的角度出发,知道什么是核心需求,什么是衍生需求,什么是无效需求。就像建火车站一样,能够满足乘客出行需求的就是好的设计方案,其他方面再细心认真,起到的也只是锦上添花的效果。那么有一些功能现在好像用不上,但又必须做,该怎么办呢?这就用到了第二个方法:迭代演进,有所主次。
|
||||
|
||||
避免过度设计和避免需求膨胀一样,我们需要时刻问自己,什么是现在就必须做的?什么是必须做的?
|
||||
|
||||
搞清楚这两个问题,有助于我们始终关注核心需求和核心问题,为代码的质量和编码的效率打好基础。
|
||||
|
||||
避免需求膨胀和过度设计,是编写经济代码最需要注意的根基性问题。
|
||||
|
||||
**2.选择简单直观**
|
||||
|
||||
我们用了两篇文章,讨论了[让代码简单直观的原则和实践](https://time.geekbang.org/column/article/82335)。
|
||||
|
||||
设计一个简单直观的接口,首先,我们要从问题开始。把问题逐步拆解成一个个已经完全穷尽的小问题,这就是我讲到的“相互独立,完全穷尽”原则。在拆解的过程中,软件的接口与接口之间的关系会自然而然地产生。
|
||||
|
||||
此外我们还要注意,一个接口只应该做一件事情,如果这个情况太理想化,就要想办法减少接口的依赖关系。
|
||||
|
||||
一定记住这个经过实践检验的理念:选择最简单,最直观的解决方案。
|
||||
|
||||
**3.超越线程同步**
|
||||
|
||||
现实中,线程同步需要排队,有损效率。我们用了两篇文章,主要讲了[该怎么超越线程的同步](https://time.geekbang.org/column/article/82954)。
|
||||
|
||||
只要满足这三个条件中的一个,我们就不需要线程同步了:使用单线程;不关心共享资源的变化;没有改变共享资源的行为。
|
||||
|
||||
我们要重新认识Java的“final”这个限定词。使用了限定词“final”的类变量,只能被赋值一次,而且只能在实例化之前被赋值。这样的变量,就是不可变的量。如果一个类的所有的变量,都是不可变的,那么这个类也是不可变的。不可变的量是无法改变的资源,不需要线程同步。
|
||||
|
||||
如果线程同步不可避免,就要想办法减少线程同步时间。
|
||||
|
||||
另外,我们还讨论了如何使用同步的代码,调动异步的事件。异步编程,可以大幅度降低线程同步的使用,更有效地使用计算机资源。
|
||||
|
||||
**4.减少内存使用**
|
||||
|
||||
内存管理对任何一门编程语言来讲都是一个难题。我们用了两篇文章,讨论了[提高内存使用效率](https://time.geekbang.org/column/article/83045)的一些方法。
|
||||
|
||||
减少内存的使用主要有两个方法,第一个方法是减少实例的数量,第二个办法是减小实例的尺寸。
|
||||
|
||||
如何减少实例的数量呢?我们可以使用数据静态化的处理方式(比如枚举类型)、用单实例模式、延迟分配技术等。
|
||||
|
||||
在减小实例尺寸这一模块,我们要尽量减少独占的空间,尽量使用共享的实例。不可变(immutable)的资源和禁止修改(unmodifiable)的资源,是两类理想的共享资源。
|
||||
|
||||
**5.规避性能陷阱**
|
||||
|
||||
我们要学会规避一些[常见的性能陷阱](https://time.geekbang.org/column/article/84096),比如字符串的操作、内存泄露、未正确关闭的资源和遗漏的hashCode等。
|
||||
|
||||
另外,我们还顺便使用了一个基准测试工具JMH,并通过它分析了一些性能陷阱。我们要有意识地使用一些性能测试工具,通过测试数据来认识、积累性能问题的最佳实践。
|
||||
|
||||
**6.规模扩张能力**
|
||||
|
||||
经济的代码需要跟得上产品的规模扩张。我们要理解规模垂直扩张和规模水平扩张这两种方式,特别是[支持规模水平扩张](https://time.geekbang.org/column/article/84488)。
|
||||
|
||||
状态数据是影响规模水平扩张能力的最重要的因素。分离无状态数据、提供无状态服务,减少有状态服务的规模,是提升规模水平扩张能力的最佳实践。
|
||||
|
||||
## 经济代码的检查清单
|
||||
|
||||
了解了编写经济代码的方法论之后,我们再来看下检查清单。这个检查清单是经济篇这一模块的凝练,也是我看代码的时候,通常会使用的检查点。你也可以参考一下。
|
||||
|
||||
如果有检查点没有通过,那么你在阅读代码的时候,就要集中注意力,深入分析;在设计和编写代码的时候,要花时间衡量、妥协、改进;在评审代码的时候,要问清楚为什么这么做,能不能有所改进,并且给出合理的建议。
|
||||
|
||||
### 需求评审
|
||||
|
||||
<li>
|
||||
需求是真实的客户需求吗?
|
||||
</li>
|
||||
<li>
|
||||
要解决的问题真实存在吗?
|
||||
</li>
|
||||
<li>
|
||||
需求具有普遍的意义吗?
|
||||
</li>
|
||||
<li>
|
||||
这个需求到底有多重要?
|
||||
</li>
|
||||
<li>
|
||||
需求能不能分解、简化?
|
||||
</li>
|
||||
<li>
|
||||
需求的最小要求是什么?
|
||||
</li>
|
||||
<li>
|
||||
这个需求能不能在下一个版本再实现?
|
||||
</li>
|
||||
|
||||
### 设计评审
|
||||
|
||||
<li>
|
||||
能使用现存的接口吗?
|
||||
</li>
|
||||
<li>
|
||||
设计是不是简单、直观?
|
||||
</li>
|
||||
<li>
|
||||
一个接口是不是只表示一件事情?
|
||||
</li>
|
||||
<li>
|
||||
接口之间的依赖关系是不是明确?
|
||||
</li>
|
||||
<li>
|
||||
接口的调用方式是不是方便、皮实?
|
||||
</li>
|
||||
<li>
|
||||
接口的实现可以做到不可变吗?
|
||||
</li>
|
||||
<li>
|
||||
接口是多线程安全的吗?
|
||||
</li>
|
||||
<li>
|
||||
可以使用异步编程吗?
|
||||
</li>
|
||||
<li>
|
||||
接口需不需要频繁地拷贝数据?
|
||||
</li>
|
||||
<li>
|
||||
无状态数据和有状态数据需不需要分离?
|
||||
</li>
|
||||
<li>
|
||||
有状态数据的处理是否支持规模水平扩张?
|
||||
</li>
|
||||
|
||||
### 代码评审
|
||||
|
||||
<li>
|
||||
有没有可以重用的代码?
|
||||
</li>
|
||||
<li>
|
||||
新的代码是不是可以重用?
|
||||
</li>
|
||||
<li>
|
||||
有没有使用不必要的实例?
|
||||
</li>
|
||||
<li>
|
||||
原始数据类的使用是否恰当?
|
||||
</li>
|
||||
<li>
|
||||
集合的操作是不是多线程安全?
|
||||
</li>
|
||||
<li>
|
||||
集合是不是可以禁止修改?
|
||||
</li>
|
||||
<li>
|
||||
实例的尺寸还有改进的空间吗?
|
||||
</li>
|
||||
<li>
|
||||
需要使用延迟分配方案吗?
|
||||
</li>
|
||||
<li>
|
||||
线程同步是不是必须的?
|
||||
</li>
|
||||
<li>
|
||||
线程同步的阻塞时间可以更短吗?
|
||||
</li>
|
||||
<li>
|
||||
多状态同步会不会引起死锁?
|
||||
</li>
|
||||
<li>
|
||||
是不是可以避免频繁的对象创建、销毁?
|
||||
</li>
|
||||
<li>
|
||||
是不是可以减少内存的分配、拷贝和释放频率?
|
||||
</li>
|
||||
<li>
|
||||
静态的集合是否会造成内存泄漏?
|
||||
</li>
|
||||
<li>
|
||||
长时间的缓存能不能及时清理?
|
||||
</li>
|
||||
<li>
|
||||
系统的资源能不能安全地释放?
|
||||
</li>
|
||||
<li>
|
||||
依赖哈希值的集合,储存的对象有没有实现hashCode()和equals()方法?
|
||||
</li>
|
||||
<li>
|
||||
hashCode()的实现,会不会产生撞车的哈希值?
|
||||
</li>
|
||||
<li>
|
||||
代码的清理,有没有变更代码的逻辑?
|
||||
</li>
|
||||
|
||||
## 小结
|
||||
|
||||
编写经济的代码,是我们在编程入门之后,需要积累的一项重要技能。正是因为要考虑性能、安全等因素,编写代码才成了一个具有挑战性的工作。
|
||||
|
||||
如果我们有以下这两个好习惯,那么编写经济的代码的能力就会越来越强大。
|
||||
|
||||
第一个习惯是,要尽早地考虑性能问题。如果你最早接触的是需求制定,就从需求开始考虑;如果你最早接触的是软件架构,就从架构层面开始考虑;如果你最早接触的是软件设计,就从软件设计开始考虑;如果你最早接触到的是代码,代码也有很多性能问题可以考虑。总之,要主动、尽早地考虑效率问题。
|
||||
|
||||
第二个习惯是,性能的实践经验需要日积月累。性能的实践经验和技术丰富繁杂,大到产品蓝图,小到每一行代码,中间还有软件的架构、选型、部署等诸多环节,都有很多的最佳实践可以积累。而且这些最佳实践,也会随着时间的推移发生变化,比如说会出现更好的技术方案,曾经的技术满足不了新需求等。所以,我们也要随时更新我们的储备,摒弃过时的经验。
|
||||
|
||||
希望你根据自己的实际情况,不断修改、完善、丰富上面的清单,让这份清单更契合你自己的工作领域。
|
||||
|
||||
## 一起来动手
|
||||
|
||||
不同的场景,检查清单也不一定相同。我上面的清单,就没有考虑数据库和Web服务架构。如果让你列一个你实际工作中需要的,编写经济代码的检查清单,会是什么样子的? 你可以在我上面的清单上加减检查点,或者新做一个列表。欢迎在留言区公布你的检查清单,我们一起来讨论、学习。
|
||||
|
||||
另外,推荐一本书《重新定义公司——谷歌是如何运营的》。如果你没有时间,看看随书附带的小册子也行。这本书,谈的虽然是公司运营,但是我们可以也从中学习到如何设计优秀的产品,如何编写优秀的代码的一些基本思想。
|
||||
|
||||
推荐的另外一本书是《Effective Java》。建议找找最新的版本(现在是第三版)。这本书里,有很多非常实用的小经验,每一个小经验都讲得深入又透彻。是一本Java程序员必备的好书。
|
||||
|
||||
如果你觉得这篇文章有所帮助,欢迎点击“请朋友读”,把它分享给你的朋友或者同事。
|
||||
|
||||
|
||||
140
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/30丨“代码经济篇”答疑汇总.md
Normal file
140
极客时间专栏/geek/代码精进之路/第二模块:代码“经济”篇/30丨“代码经济篇”答疑汇总.md
Normal file
@@ -0,0 +1,140 @@
|
||||
<audio id="audio" title="30丨“代码经济篇”答疑汇总" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/22/97/228eb7eb7c29864857ac6ff2dbe11897.mp3"></audio>
|
||||
|
||||
到这一篇文章,意味着专栏第二模块“经济的代码”已经更新完毕了。非常感谢同学们积极踊跃地留言,提出了很多独到的见解,我自己也学到了不少新东西。
|
||||
|
||||
今天,我来集中解答一下留言区里的一些疑问。有很多问题,我们已经在留言区里讨论了。这里,我们就挑选一些还没有解决掉的问题,深入讨论一下。
|
||||
|
||||
**@秦凯**
|
||||
|
||||
>
|
||||
对性能和资源消耗有一定的意识,但是在具体的开发过程中或者应用运行过程中要对性能进行监控、评测、分析,就束手无策了。
|
||||
|
||||
|
||||
答:我一直都认为,有了意识,其实就成功一大半了。有了意识,我们就会去寻找技术,寻找工具,寻找解决的办法。到了这个专栏的第三个部分(也是接下来要更新的“安全篇”),我们就会更强烈地感受到,“要有意识”是我们首先要获得的能力。大部分代码的问题,其实都是意识和见识带来的问题。
|
||||
|
||||
回到这个问题本身,性能的监控、评测和分析,我们通常要通过一定的工具来解决。
|
||||
|
||||
第一个常用的工具是JMH,我们在[第26篇](https://time.geekbang.org/column/article/84096)里简单介绍了这个小工具的用法。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篇](https://time.geekbang.org/column/article/83504)):延迟分配的例子中,为什么要使用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讲》](https://time.geekbang.org/column/article/10772)和郑雨迪老师的[《深入拆解 Java 虚拟机》](https://time.geekbang.org/column/article/13484)专栏里,都有讲解。详细的技术细节,请参考两位老师的文章(点击链接即可直接看到文章)。
|
||||
|
||||
简单地说,线程的堆栈、CPU的缓存、计算机的内存,可以是独立的物理区域。共享数据的读取,要解决好这些区域之间的一致性问题。也就是说,不管从线程堆栈读写(线程内),还是从CPU缓存读写(线程间),还是从计算机的内存读写(线程间),对于每个线程,这些数据都要一样。这就需要在这些不同的区域之间做好数据同步。
|
||||
|
||||
我们再来看这个例子。声明为volatile的helloWordsMap是一个共享的资源。它的读写,需要在不同的线程间保持同步。而同步有损效率。有没有办法降低一点读写的频率呢?
|
||||
|
||||
如果我们不使用共享的资源,也就没有了数据在不同内存间同步的需求。temporaryMap变量是一个方法内的局部变量,这个局部变量,只在这个线程内起作用,不需要和其他线程分享。所以,它的访问就不存在同步的问题了。
|
||||
|
||||
把共享的volatile变量的引用,赋值给一个局部的临时变量,然后使用临时变量进行操作,就起到了降低共享变量读写频率的效果。
|
||||
|
||||
这种办法有一个适用场景,就是volatile变量的引用(地址)一旦初始化,就不再变更。如果volatile变量的引用反复变更,这种办法就有潜在的数据同步的问题了。
|
||||
|
||||
以上就是答疑篇的内容。如果这些问题是你和朋友,或者同事经常遇到的问题,不妨把这篇文章分享给他们,一起交流一下。
|
||||
|
||||
从下一篇文章起,我们就要开始这个专栏的第三部分“安全的代码”的学习了。在这一部分,我们将主要采用**案例分析**的形式来进行学习。下一篇文章见!
|
||||
|
||||
|
||||
Reference in New Issue
Block a user