first commit
This commit is contained in:
parent
633f45ea20
commit
206fad82a2
176
专栏/JavaScript进阶实战课/00开篇词JavaScript的进阶之路.md
Normal file
176
专栏/JavaScript进阶实战课/00开篇词JavaScript的进阶之路.md
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
00 开篇词 JavaScript的进阶之路
|
||||||
|
你好,我是石川,欢迎和我一起学习JavaScript。
|
||||||
|
|
||||||
|
JavaScript的前身Mocha,是布兰登·艾克(Brendan Eich)在1995年用10天时间设计出来的。我第一次接触JavaScript,是从它诞生算起的10年后,在学校学习的,那时的网站更多是静态的信息展示类网页。所以当时,JavaScript和它刚发明出来时类似,更多是一门脚本语言。可以说,整个前端在那个年代和之前的很长时间,都被认为是一个很业余的技能。
|
||||||
|
|
||||||
|
但随着AJAX在2005年诞生,接着W3C在2006年第一次发布了XHR(XMLHttpRequest)规范草案后,这种情况就改变了。基于AJAX,我们不仅可以开发静态展示的页面,也可以开发动态交互的应用。到这时候,JS才逐渐被重视起来,之后AJAX相关技术也被纳入了网络标准。我第一次开发AJAX相关的应用,是2007年毕业后给雅虎开发的一个集合各种Web API (Flickr, Yahoo Answers,类似知乎)的应用门户。所以,我完美地赶上了它的转折期。
|
||||||
|
|
||||||
|
在此之后,JavaScript就从一门简单的脚本语言,发展成了可以用来开发复杂应用的语言。但即使如此,当时的JavaScript本身并没有太大变化,仍然无法像Java或C++一样,拥有强大的标准库,因此开发者们需要自己想办法去解决很多问题,比如当时最大的问题,就是要解决系统兼容性,这也导致当年涌现了很多第三方库。
|
||||||
|
|
||||||
|
而我也是在同一时间,接触了开源,把平时自己踩过的坑以及解决方案写成了小工具。再后来,我有幸和Google、Twitter的一些工程师合作,创建了 HTML5 Boilerplate开源项目,为的就是解决一些前端经常遇到的兼容性问题。之后,我在英国出版了一本《HTML5 移动Web开发实战》,以及继续从事一些开源工作,后续推出了开源项目 JavaScript Patterns & Anti-Patterns。
|
||||||
|
|
||||||
|
而随着Web应用的进一步发展,我在工作中也面临了更大的挑战。最近几年,我和腾讯、阿里合作了一些大型前端项目。在做项目的过程中,我发现随着应用使用者的大规模增加,很多问题产生的副作用也会呈现指数级上升,性能、安全等非功能性的问题也会不断显露。
|
||||||
|
|
||||||
|
举个例子,我曾在项目中负责一个大型订票应用的开发,由于业务流程复杂、业务需求也瞬息万变,因此一个很小的副作用就会造成大量订单问题,这时就要用到纯函数思想中的幂等,来保证任意多次执行结果与一次执行结果相同,避免订单的重复提交。
|
||||||
|
|
||||||
|
除了由于业务复杂度、高并发引起的副作用外,在大量访问的情况下,一个很小的资源加载对资源的消耗就是指数级的,那在强调降本增效的今天,能够实现对资源的有效控制,对我们做业务来说是十分关键的一点。比如说,如果通过函数式+响应式编程,我们就可以通过用户的实时需求动态加载资源,从而能够节省不必要的资源预加载成本。
|
||||||
|
|
||||||
|
这样算下来,我从接触前端,再到使用JavaScript进行开发工作,已经有十余年的时间。在这个过程中,我遇到过太多前端/JavaScript方面的疑难杂症,因此在接下来的时间里,我会将我的JavaScript实践经验一一分享给你,希望能帮助你建立起系统解决问题的思维、掌握实际处理问题的方法。
|
||||||
|
|
||||||
|
为什么要学这门课?
|
||||||
|
|
||||||
|
现在,JavaScript早已不是当年的一个脚本语言,随着Web应用和Node.js的兴起、函数式编程的复兴,以及响应式编程开始进入人们视野,让JavaScript看上去更“专业”了。虽然和十几年前相比较,JavaScript也加入了很多功能和语法糖,但是它的核心原理并没有太大变化。
|
||||||
|
|
||||||
|
可即使没有太多本质上的变化,JavaScript也仍然具有容易入门但难以进阶的问题。
|
||||||
|
|
||||||
|
我认为造成这个问题的原因主要有两个:一是早期写JavaScript的很多程序员的编程基础并不扎实,知识点很杂,导致大家对JS的理解不够深入;二是后来入局的一些大咖,很多都是从其它语言转来的,他们认为一些知识没法分享和讲解。
|
||||||
|
|
||||||
|
|
||||||
|
拿函数式编程中的Monad举例,道格拉斯·克罗克福特(Douglas Crockford,他帮助改良了JavaScript)就曾经说过:Once someone understands Monads, they lose the ability to explain it to anybody else.-
|
||||||
|
大意是你一旦会用Monad,你就不知道怎么给别人解释它了。
|
||||||
|
|
||||||
|
|
||||||
|
这就使得JavaScript的开发者两极分化很严重,一部分一直停留在入门级,一部分出道即巅峰。
|
||||||
|
|
||||||
|
所以这门课的初衷,就是让学习JavaScript的你,能够对这个开始比较不那么“专业”的语言,有一个系统的专业理解。帮助你一步一个脚印,把点连成线,把线连成面,把面搭建起一座空间立体的“思维大厦”。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
看到这座“大厦”,你可能也会望而却步,觉得知识体系十分庞杂和分散,学习起来很可怕。不过不用担心,万丈高楼平地起,只要你结合实际,按照我分享的方法掌握了知识的脉络,理解起来、用起来就并不复杂。
|
||||||
|
|
||||||
|
那么我的方法是什么呢?下面就跟你具体说道说道。
|
||||||
|
|
||||||
|
怎样学才能少走弯路?
|
||||||
|
|
||||||
|
很多人认为,编程模式、数据结构和算法可以脱离语言本身,因为无论用什么语言都会用到这些模式。
|
||||||
|
|
||||||
|
在我看来,它们确实可以抽象出来,但是基于语言的特点,我们能更好地理解这些模式的应用。毕竟,无论是读别人的代码,还是让机器有效执行程序,“语言”才是我们交流的工具。
|
||||||
|
|
||||||
|
函数式编程
|
||||||
|
|
||||||
|
打个比方,函数式编程和响应式编程之所以崛起或者说复兴,和实际应用是分不开的。因为在前端开发中,我们的应用面对的是UI客户端,所以应用背后的程序,就需要处理大量的用户和网络触发的事件,并根据事件的状态来做出响应。
|
||||||
|
|
||||||
|
而函数式和响应式编程的很多思想,正好可以帮助这一目的的实现,开发者和用户是用脚投票的,所以才出现了复兴。而不是单单基于某个理论,或者要和面向对象比个高下,才得到广泛应用的。
|
||||||
|
|
||||||
|
但是我也发现,如果脱离了实际的语言和它解决的问题,来解释编程模式,就会高度抽象,显得比较形而上,还是拿函数式编程中很重要的概念Monad来说明,其官方解释是这样的:
|
||||||
|
|
||||||
|
|
||||||
|
All told, a monad in X is just a monoid in the category of endofunctors of X, with product × replaced by composition of endofunctors and unit set by the identity endofunctor.-
|
||||||
|
总而言之,X 中的单子只是 X 的内函子类别中的一个monoid,乘积 × 被内函子的组合替换,单位由恒等函子设置。
|
||||||
|
|
||||||
|
|
||||||
|
这样的解释,相信会让想要了解函数式编程的你头皮发麻,更不要说去掌握它的强大功能了。虽然它的解释并没有错,因为这就是一个数学上的概念,需要严谨且客观。但问题是,它和我们做开发有什么关系呢?
|
||||||
|
|
||||||
|
那么,要我来说的话,对于“为什么需要函数式和响应式编程”这个问题,用一个很简单的生活例子其实就能解释清楚了。
|
||||||
|
|
||||||
|
你应该也有感触,这两年全球范围内黑天鹅事件频发,我们感到世界的秩序在重置,过去我们面对繁杂和复杂,还可以“摸着石头过河”;可是现在,连石头都没有了,我们感到的就是“混乱”。
|
||||||
|
|
||||||
|
威尔士学者戴夫·斯诺登(Dave Snowden),在1999年供职于IBM期间,提出过一个Cynefin框架,就是来解决这种“混乱”的。他认为,在面对混乱时,应该按这三步走:行动、感知、响应。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这和我们的应用有什么关系?
|
||||||
|
|
||||||
|
我们其实可以结合JS语言对函数式编程的支持,以及它相关领域的前端应用来理解。在前端开发中,人机交互是很复杂的。比如我们加载了一个界面后,根本不知道用户会做出什么反应,或者点击哪个按钮。那我们怎么办呢?其实就是先把初始页面加载下来,也就是第一步,行动。
|
||||||
|
|
||||||
|
那么,当用户点击了一个按钮,比如一个日历的时候,我们就进行到了第二步,即感知到用户想要选择一个日期。所以,我们就可以给这个按钮加上事件处理器(event handler),完成相关的日历组件或模块加载,通过之前“记住”的用户需求,展示加载后的日历,这也就是第三步,响应。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在这个过程当中,我们要通过工具来处理很多事,比如事件处理、状态管理。函数就是这个工具,它的核心就是IO输入输出。我们给函数一个输入值(数据结构),它进行处理(算法),然后给我们一个输出值(返回值)。
|
||||||
|
|
||||||
|
而Monad的核心,就是围绕一个“值”来组织各种方法。所以无论是JavaScript本身带的 array.map,还是我们常用的React.js里面的observer、reducer, 其实万变不离其宗,都是在复杂系统、不确定性和混沌中,帮助我们去行动、感知和响应,解决各种副作用,提高性能的方法和工具。
|
||||||
|
|
||||||
|
所以,函数式、响应式编程不是远在天边的概念,而是近在眼前的生产力,如果你没有看到,可能只是缺少一双发现它们的眼睛。
|
||||||
|
|
||||||
|
面向对象编程
|
||||||
|
|
||||||
|
如果说在使用函数式编程的时候,我们考虑的是“生产力”,那在使用面向对象的时候,我们考虑的更多是“生产关系”。我想你在学习JavaScript之前可能也有其它的语言背景,会习惯性地将class等概念照搬到JavaScript,而忽略JavaScript本身的一些特点。
|
||||||
|
|
||||||
|
比如JavaScript中的继承,是基于原型链的继承,更偏向delegation而不是inheritance。即使在面向对象设计本身,也是追求多用组合少用继承。所以,我们可以看看JavaScript在面向对象中,有哪些自己的特点,来组织生产关系。
|
||||||
|
|
||||||
|
在课程中,我们并不会用二极管的思维去说哪一种模式就是银弹,而是会根据实际情况,找到合适的方法。
|
||||||
|
|
||||||
|
数据结构和算法
|
||||||
|
|
||||||
|
说到数据结构和算法,相信无论是刷Leetcode还是看极客时间上的课程,你都可以掌握,而且虽然不能说绝对,但算法确实相对独立于语言。有朋友曾经问过我,学习数据结构和算法应不应该刷题,我其实认为应该,有的时候哪怕是应试,也是学习的一种方法。
|
||||||
|
|
||||||
|
但是在讲这部分知识的时候,我并不会应试地讲,因为你完全可以在市面上找到类似的内容。在课程里,我希望能带你在学习JavaScript的引擎、浏览器和编译原理的过程中,来理解这些数据结构和算法,这样你更能理解这门语言的运行机制和原理,起到和专门讲算法的课程相辅相成的作用。比如你如果不明白stack、queue、heap,就几乎很难理解哪怕比较基础的闭包和事件循环,更不要说基于这些机制来做程序开发时的考量。
|
||||||
|
|
||||||
|
这门课的设计思路
|
||||||
|
|
||||||
|
那么,基于以上思考,我也总结了学习JavaScript的几大痛点。
|
||||||
|
|
||||||
|
首先,如前面所说,正是因为JavaScript是一门学习门槛不高的语言,虽然上手快,但是容易缺乏对底层逻辑的理解,这会从很大程度上限制我们的代码质量和解决更复杂问题的能力。
|
||||||
|
|
||||||
|
其次,也会导致很多理论知识,我们很难学以致用。
|
||||||
|
|
||||||
|
最后,就是由于前端涉及的知识点非常广,比如你要理解JavaScript的运行机制,就要了解计算机组成、引擎、浏览器和编译,如果每个知识点都跳来跳去地看,消耗的无效精力可能会是几倍、几十倍。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
所以在设计这门课的时候,我主要划分了五个模块来解决这些问题。
|
||||||
|
|
||||||
|
|
||||||
|
JavaScript之道
|
||||||
|
|
||||||
|
|
||||||
|
第一个模块,我们先来讲讲函数式和面向对象的编程模式,毕竟其中的一些核心概念或元认知(metacognition)即使不是恒久不变,也至少是到目前为止,经受住了时间考验的核心理论和实践。因此,当你理解和掌握了这两类核心编程范式之后,你就知道要如何结合JavaScript的特性进行取长补短了,也能够因地制宜地解决实际问题了。
|
||||||
|
|
||||||
|
|
||||||
|
JavaScript之法
|
||||||
|
|
||||||
|
|
||||||
|
接下来,我会带你来学习JavaScript的底层逻辑和所用到的数据结构与算法,以此帮助你写出更高效的代码。我会从大量的开源项目等案例出发,带你了解、学习和掌握JS引擎及浏览器在编译和运行时的一些特点,帮助你达成对这些知识点的真正理解,最后能够融会贯通。这样你在使用JS的一些功能,如排序或者做代码优化的时候,就能够更好地抓住重点,管理预期。
|
||||||
|
|
||||||
|
|
||||||
|
JavaScript之术
|
||||||
|
|
||||||
|
|
||||||
|
在理解了JavaScript的数据结构与算法之后,我们还要一起来看看它用到的设计模式。前面我也强调过JS编程模式(函数式+响应式及面向对象)的重要性,而设计模式其实是前面模块的延续。这个部分,我会结合一些三方的库,来帮助你理解和掌握如何通过设计模式进一步提高“生产力”,优化“生产关系”。
|
||||||
|
|
||||||
|
|
||||||
|
JavaScript之器
|
||||||
|
|
||||||
|
|
||||||
|
我们知道,通过工具的有效使用,可以减少重复的工作,帮助我们提高开发质量和效率。因此在这个模块中,我们依然是从案例出发,来了解、学习JavaScript中的常用工具及其背后的使用原理、使用场景,让你能够通过对原理和实践经验的理解,更好地为开发赋能。
|
||||||
|
|
||||||
|
|
||||||
|
JavaScript之势
|
||||||
|
|
||||||
|
|
||||||
|
我们说唯一不变的就是变化本身,通过前面对JavaScript知识体系的系统性理解,最后,我们也来看看前端一些新的技术趋势,了解下这些变化和趋势会对我们产生哪些影响,以此进一步巩固知识体系,进阶为一名JavaScript语言应用强者。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
写在最后
|
||||||
|
|
||||||
|
最后我想说,学习是一件需要长期坚持做的事。我希望在接下来的更新时间里,你能坚持每讲都学完,并在评论区参与讨论。不过我也知道,这说起来容易,做起来其实很难,毕竟学习本身就是一件反人性的事儿。
|
||||||
|
|
||||||
|
那如果这么难,如何才能真正坚持学习课程呢?我没有标准答案,但是我想结合自己的一些经验,来给你分享一些可行的方法,希望能对你有所帮助。
|
||||||
|
|
||||||
|
|
||||||
|
第一就是在学习一节知识时,尽量一鼓作气,有些概念即使模糊,你硬着头皮看下去也比停顿去深入了解某个点强。实在遇到困难的地方,你可以简单查下资料,但只要查到刚刚能让你继续往下读的内容即可,千万不要偏离了每节课的主题。
|
||||||
|
第二是可以反馈意见,我们承诺会帮助;如果你对某个知识点有更简单的描述、更独特的见解,也欢迎你在评论区多分享。
|
||||||
|
第三是可以让你的好朋友也一起学习课程,一起成长。读万卷书,不如行万里路,行万里路有时不如沟通和交流,这就像是编程中的Peer programming。
|
||||||
|
|
||||||
|
|
||||||
|
另外,为了让你学起来没那么难,我也借用函数式和面向对象编程模式中常用的两个思想,“声明式”和“基于接口而非实现”来设计这门课。也就是把这个过程当做一个对话,尽量用平实的语言、易读的代码和直观的图片,来帮助你理解一些生涩难懂的概念。
|
||||||
|
|
||||||
|
最后,也感谢你的信任,希望通过这门课,我们可以一起和时间做朋友,共同成长!通过对话,我们彼此也能成为终身学习之路上的好朋友。
|
||||||
|
|
||||||
|
延伸阅读
|
||||||
|
|
||||||
|
|
||||||
|
Cynefin framework
|
||||||
|
Import on Interaction
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
312
专栏/JavaScript进阶实战课/01函数式vs.面向对象:响应未知和不确定.md
Normal file
312
专栏/JavaScript进阶实战课/01函数式vs.面向对象:响应未知和不确定.md
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
01 函数式vs.面向对象:响应未知和不确定
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
编程模式(programming paradigm)可以说是编程语言的元认知。从编程模式的角度看JavaScript,它是结构化的、事件驱动的动态语言,且支持声明式和指令式两种模式。所以我们说,JavaScript是一个多模式(multi-paradigm)的语言,也是一门“丰富”的语言。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在JavaScript所支持的编程模式中,用得最多的是面向对象(OOP object oriented programming)和函数式(FP functional programming)两种,其中又以面向对象的普及率最高。现在介绍面向对象的书已经很多了,函数式因为受众相对小一些,支持的语言少一些,所以被提及的也相对比较少。
|
||||||
|
|
||||||
|
我猜你也许已经对这两种编程模式有所了解,甚至会比较熟悉,但我之所以还是要在第一节课去强调这个话题,是因为你在学习JavaScript时,可能会面对以下至少 1个核心痛点:
|
||||||
|
|
||||||
|
|
||||||
|
如果你已经学过传统的面向对象语言,那么在学JavaScript的时候,很可能对函数式的理解和运用不够深入;
|
||||||
|
反之,如果你一开始就学习JavaScript,只是停留在开发一些简单应用上,可以说你对它的面向对象的理解和运用,也不会很深入。
|
||||||
|
|
||||||
|
|
||||||
|
这两种学习困扰,很多时候会导致我们刚知道了点概念,就碰上了千奇百怪的副作用,然后我们发现还需要去学习解决它的办法,最后往往很容易就放弃了。
|
||||||
|
|
||||||
|
补充:在开篇词里,我提到函数式+响应式编程可以对抗不确定性。这个概念不只是在编程中,它也是一个跨学科的研究。比如在AI、机械和航空航天工程这些硬科技的领域,以及很多知名的大学(如伯克利、麻省理工)和政府机构(如NASA),都对System Dynamics and Controls开展了很深入的研究。其核心就是研究在动态情况下如何做到系统控制,其中很重要的一点就是处理波动和干扰。
|
||||||
|
|
||||||
|
而在函数式编程中,我们通常会把各种干扰,就叫做副作用(Side effect)。
|
||||||
|
|
||||||
|
所以接下来,我会先带你从“思维大厦”的顶层开始,来了解JavaScript语言的核心思想,然后再带你看看如何因地制宜地使用这两种编程模式来解决问题。这样一来,你在日后面对已知和未知问题,做复杂系统开发的时候,也能找到一套行之有效的方法了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
函数式编程
|
||||||
|
|
||||||
|
首先,我们一起来看看函数式编程,了解下函数是什么、它是做什么用的、在编程中可能会有哪些副作用,以及如何利用JavaScript的核心设计思想和工具,解决这些副作用。
|
||||||
|
|
||||||
|
函数是什么、如何使用?
|
||||||
|
|
||||||
|
一个函数由输入、函数和输出组成,这和我们在初中就学过的函数一样,函数是数据集到目标的一种关系,它所做的就是把行为封装起来,从而达到目标。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
举一个最简单的例子:我们要实现一个“计算消费税”的工具,目标是通过产品价格计算出消费税。
|
||||||
|
|
||||||
|
以下代码中的productPrice是输入的形参(parameter),产品价格100元是传入的数据实参(argument),而calculateGST这个功能就是封装算法的函数本身;代码中输出的5,就是返回值(returned value),也就是消费税5元。
|
||||||
|
|
||||||
|
function calculateGST( productPrice ) {
|
||||||
|
return productPrice * 0.05;
|
||||||
|
}
|
||||||
|
calculateGST(100); // return 5
|
||||||
|
|
||||||
|
|
||||||
|
其实,很多开发者常用的jQuery就是一个工具集。我们打开jQuery在GitHub的源代码,可以看到里面有大大小小的工具助手。比如下面这个isArrayLike函数,就是一个帮助我们判断一个对象是不是类似数组的功能。这个功能也可以独立于jQuery库存在,这就是函数式编程最基本的使用。
|
||||||
|
|
||||||
|
function isArrayLike( obj ) {
|
||||||
|
var length = !!obj && obj.length,
|
||||||
|
type = toType( obj );
|
||||||
|
|
||||||
|
if ( typeof obj === "function" || isWindow( obj ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return type === "array" || length === 0 ||
|
||||||
|
typeof length === "number" && length > 0 && ( length - 1 ) in obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
所以,通过isArrayLike可接受的参数可见,函数的输入值不仅可以是一个基础类型数据(primitive type),比如前面例子中的数字或者字符串;也可以是一个相对复杂些的对象类型数据(object type),包括对象本身和数组。甚至,函数本身作为对象,也可以是输入或输出值,我们把这种函数就叫做高阶函数(higher order functions)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
函数中都有哪些副作用?
|
||||||
|
|
||||||
|
前面我们说过,函数已经把算法封装了起来,那么函数里相对就是可控的,而比较不可控的是外部环境。这里,我们可以把不可控的外部环境分为三大类。
|
||||||
|
|
||||||
|
第一类,函数中最常见的副作用,就是全局变量(global variable)。比如下面的例子里,我们首先定义了一个全局变量x,之后每次在log它的值之前,都执行了不同的函数,但我们没法保证这些函数没有改变这个变量的值,也没法保证每次输出的结果是1。所以从输入开始,这种不确定性就存在了。
|
||||||
|
|
||||||
|
var x = 1;
|
||||||
|
foo();
|
||||||
|
console.log( x );
|
||||||
|
bar();
|
||||||
|
console.log( x );
|
||||||
|
baz();
|
||||||
|
console.log( x );
|
||||||
|
|
||||||
|
|
||||||
|
除了全局变量以外,另一个比较明显的问题就是 IO影响(IO effects)。这里的IO说的不是前面函数里的参数和返回值,而是类似前端浏览器中的用户行为,比如鼠标和键盘的输入,或者如果是服务器端的Node的话,就是文件系统、网络连接以及stream的stdin(标准输入)和stdout(标准输出)。
|
||||||
|
|
||||||
|
第三种比较常见的副作用是与网络请求(HTTP request)相关,比如我们要针对一个用户下单的动作发起一个网络请求,需要先获得用户ID,再连着用户的ID一起发送。如果我们还没获取到用户ID,就发起下单请求,可能就会收到报错。
|
||||||
|
|
||||||
|
减少副作用:纯函数和不可变
|
||||||
|
|
||||||
|
那么我们要如何减少以上这些副作用呢?在函数式编程中,有两个核心概念:纯函数(pure function)和不可变(immutability)。
|
||||||
|
|
||||||
|
这是一个“双循环”,纯函数更多解决的是“内循环”;而不可变更多考虑的是“外循环”。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
纯函数的意思是说,一个函数的返回结果的变化只依赖其参数,并且执行过程中没有副作用。也就是说打铁还需自身硬,面对外界的复杂多变,我们要先保证函数封装的部分本身是稳固的。比如前面消费税计算器的例子,当输入的产品价格参数为100时,输出的结果永远是5。无论有什么干扰,它都不会返回一个不是5的数字,除非你换一个参数。
|
||||||
|
|
||||||
|
我们再来看下面这个例子,当把税率从函数中抽离出来,放在函数外作为变量时,它就不是一个纯函数了,因为随着这个变量的变化,计算结果会有所不同。所以,纯函数就可以通过减少对外界不确定因素的依赖,来减少副作用。
|
||||||
|
|
||||||
|
var rate = 0.05;
|
||||||
|
function calculateGST( productPrice ) {
|
||||||
|
return productPrice * rate;
|
||||||
|
}
|
||||||
|
calculateGST(100); // return 5
|
||||||
|
|
||||||
|
|
||||||
|
除了纯函数,函数式编程解决副作用的另一个核心思想,就是不可变。这个如何理解呢?我们可以通过JavaScript中自带的splice和slice来举例。
|
||||||
|
|
||||||
|
const beforeList = [1,2,3,4]
|
||||||
|
console.log(beforeList.splice(0,2))
|
||||||
|
console.log(beforeList.splice(0,2))
|
||||||
|
//[ 1, 2 ]
|
||||||
|
//[ 3, 4 ]
|
||||||
|
|
||||||
|
const beforeList = [1,2,3,4]
|
||||||
|
console.log(beforeList.slice(0,2))
|
||||||
|
console.log(beforeList.slice(0,2))
|
||||||
|
//[ 1, 2 ]
|
||||||
|
//[ 1, 2 ]
|
||||||
|
|
||||||
|
|
||||||
|
可以看到,数组中的splice方法,在对数据进行了处理后,改变了全局中的beforeList的值,所以是可变的。而slice在执行之后的结果,没有影响全局中的beforeList的值,所以它是不可变的。也是因为这样,在开发中,如果要保证不可变,我们就不能用splice,而用slice。
|
||||||
|
|
||||||
|
所以,不可变就是在减少程序被外界影响的同时,也减少对外界的影响。因为如果你把一个外部变量作为参数作为输入,在函数里做了改变,作为输出返回。那么这个过程中,你可能不知道这种变化会对整个系统造成什么样的结果。
|
||||||
|
|
||||||
|
而且在数组中,你还可以看到更多类似splice和slice这种纯函数、非纯函数,以及可变与不可变的例子。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
另外,从纯函数和不可变的设计思想中,我们还可以抽象出一个概念。
|
||||||
|
|
||||||
|
因为“副作用”首先是一个作用(effect),而作用遵循的是一个因果(cause and effect)关系。那么,从值的角度来看,“纯函数”对值只影响一次,而“不可变”完全不影响。
|
||||||
|
|
||||||
|
如何理解“纯函数”对值只影响一次呢?这里有一个幂等(idempotence)的概念。如果你做过大型的交易类应用的话,应该对这个概念不陌生。比如说,有时用户对同一个订单多次重复发出更新请求,这时返回的结果不应该有差别。
|
||||||
|
|
||||||
|
在数学中,幂等的意思是不管我们把一个函数嵌套多少次来执行,它的结果都应该是一样的。比如在这个Math.round四舍五入的例子里,无论你嵌套执行几次,结果都是一样的。
|
||||||
|
|
||||||
|
//数学幂等
|
||||||
|
Math.round(((0.5)))
|
||||||
|
|
||||||
|
|
||||||
|
在计算机中,幂等的意思是一个程序执行多次结果是一样的。比如,假设我们有一个adder函数,3和4相加永远返回7。所以,你其实可以把数学里的概念迁移过来。
|
||||||
|
|
||||||
|
//计算机幂等
|
||||||
|
adder (3, 4) // 返回 7
|
||||||
|
adder (3, 4) // 返回 7
|
||||||
|
|
||||||
|
|
||||||
|
好,然后我们再来看看如何理解“不可变”对值完全不影响。
|
||||||
|
|
||||||
|
通过前面array slice和splice的例子,你应该能感觉到,splice更像是一块橡皮泥,一开始它可能是个方块儿,你可以捏出腿和脑袋,它就成了一个小人儿,也就是说它本身发生了变化。而slice在处理完后是形成了一个新的数组,但原始的数组完好无损,它是把值当成乐高积木,而不是橡皮泥。把这种思想用到状态管理里,你就会记录状态的变化,而不会篡改状态。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
总之,我们可以看到函数式编程最核心的地方,就是输入输出和中间的算法,我们要解决的核心问题就是副作用。而为了解决副作用,我们需要掌握两个重要的概念,一个是纯函数,一个是不可变。纯函数强调的是自身的稳定性,对结果只影响一次;而不可变强调的是和外界的交互中,尽量减少相互间负面的影响。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
面向对象编程
|
||||||
|
|
||||||
|
我们再来看看面向对象。如前面所说,如果我们用函数来做一个税率计算工具,判断一个数是不是类数组的对象,是没问题的,而且我们可以放心,如果希望它“纯粹”,那么它运行的结果就可以基于我们定义的法则,没有惊喜,没有意外。那这样不就足够了?为什么我们还需要对象?下面我们就来看看。
|
||||||
|
|
||||||
|
对象是什么、如何创建?
|
||||||
|
|
||||||
|
开篇词里,我说过一个“摸着石头过河”的例子,首先得有站在岸边的“你”,这个“你”就是对象,如果没有对象,就算是有一个工具(function),比如有快艇在岸边,它也只能停靠在那儿;或者你有游泳这个方法(method),但它也只有在你身上才能发挥作用。
|
||||||
|
|
||||||
|
这其实就是对象的意义。我们在做业务系统开发的时候,会面对各种各样的业务对象,比如“表单”“购物车”“订单”,这些都可以看做是对象。所以我们说,工具和方法通常是服务于对象的。
|
||||||
|
|
||||||
|
举个例子,假设我们有一个微件对象,我们想定义一个属性是它的微件名称widgetName,并给它定义一个identify的功能来识别自己的名称,那么在JavaScript中,其实就可以通过以下代码来实现:
|
||||||
|
|
||||||
|
var widget = {
|
||||||
|
widgetName : "微件",
|
||||||
|
identify : function(){
|
||||||
|
return "这是" + this.widgetName;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(widget.widgetName); // 返回 "微件"
|
||||||
|
console.log(widget.identify()); // 返回 "这是微件"
|
||||||
|
|
||||||
|
|
||||||
|
为什么需要封装、重用和继承?
|
||||||
|
|
||||||
|
实际上,如果说函数加对象组成了生产力,那么封装、重用和继承则可以用来组成生产关系。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
封装最常见的使用就是在我们做组件化设计的时候,比如在一个旅行网站页面中,我们看到的筛选器、日历、结果区域都可以看做是不同的模块(module)或组件( component),这些组件是通过封装导入加载到页面的。
|
||||||
|
|
||||||
|
重用就是把可以重复使用的功能抽象到一个类里,每次只是创建一个它的实例对象来使用。比如我们的页面上有很多按钮,它们的功能大同小异,这时我们就可以把它抽象出来成为一个类(class),每一个按钮都是一个按钮类中的实例(instance)。
|
||||||
|
|
||||||
|
当然,上面我们说的按钮可能虽然功能上大同小异,但还是有具体差别。这时,我们可以把通用功能放到抽象类;而一些特定的行为或属性,我们可以通过继承放到实现类中,这样在继承了基础的父类(parent class)功能的基础上(extend),我们能够在子类(child class)中作一些改动。
|
||||||
|
|
||||||
|
但是如果一个程序中,父子的层级过于复杂,也会变得“官僚化”,如果父类有了问题,就会牵一发动全身,而且抽象的层级过多,也会让代码难以理解。
|
||||||
|
|
||||||
|
实际上,在面向对象中,也有组合的概念,就是一个子类不是继承的某个父类,而是通过组合多个类,来形成一个类。这也很像我们如今的职场,公司为了应付外界竞争压力,内部会有一个个的敏捷团队,通过每个成员自身价值和团队“组合”产生1+1>2的价值,而不是强调依靠某种从属关系。
|
||||||
|
|
||||||
|
所以,在面向对象的编程中,也有“组合”优于“继承”的概念。不过在实际情况下,继承也不是完全不可取的,在开发中,我们使用哪种思想还是要根据情况具体分析。
|
||||||
|
|
||||||
|
什么是基于原型的继承?
|
||||||
|
|
||||||
|
好,既然说到了继承,那我们还需要明确一个问题,什么是基于原型的继承?
|
||||||
|
|
||||||
|
这里我们首先要搞清楚一点:JavaScript中的类和其它面向对象的语言,究竟有什么不同?
|
||||||
|
|
||||||
|
对于传统的面向对象的编程语言来说,比如Java,一个对象是基于一个类的“蓝图”来创建的。但是在JavaScript中,就没有这种类和对象的拷贝从属关系。实际上,JS里的对象和“类”,也就是构建函数之间是原型链接关系。
|
||||||
|
|
||||||
|
比如,在下图左边基于类的例子中,以一个类作为蓝图,可以创建两个实例。而右边基于原型的例子里,我们可以看到通过一个构建函数构建出的两个对象,是通过原型链和构建函数的原型相连接的,它们并不是基于一个蓝图的复制拷贝和从属关系。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
虽然后来JavaScript在ES6之后也加入了类,但实际上它只是运用了语法糖,在底层逻辑中,JavaScript使用的仍然是基于原型的面向对象。
|
||||||
|
|
||||||
|
在ES6+中,class的语法糖用法基本和之前的类似,只是把function变成了class:
|
||||||
|
|
||||||
|
class Widget {
|
||||||
|
constructor (){
|
||||||
|
// specify here
|
||||||
|
}
|
||||||
|
notice(){
|
||||||
|
console.log ("notice me");
|
||||||
|
}
|
||||||
|
|
||||||
|
display(){
|
||||||
|
console.log ("diaplay me");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var widget1 = new Widget();
|
||||||
|
|
||||||
|
widget1.notice();
|
||||||
|
widget1.display();
|
||||||
|
|
||||||
|
|
||||||
|
好,我们再通过一个例子来实际观察下原型链。下面的代码中,我们是通过函数自带的call()方法和对象自带的Object.create()方法,让Notice作为子类继承了Widget父类的属性和方法,然后我们创建了两个实例notice1和notice2。
|
||||||
|
|
||||||
|
而这时,我们如果用getPrototypeOf来获取notice1和notice2的原型,会发现它们是等于Notice原型。当我们用display方法调用这个方法时,实际调用的是原型链里Notice的原型中的方法。
|
||||||
|
|
||||||
|
function Widget(widgetName) {
|
||||||
|
this.widgetName= widgetName;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget.prototype.identify = function() {
|
||||||
|
return "这是" + this.widgetName;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Notice(widgetName) {
|
||||||
|
Widget.call(this, widgetName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notice.prototype = Object.create(Widget.prototype);
|
||||||
|
|
||||||
|
Notice.prototype.display= function() {
|
||||||
|
console.log("你好, " + this.identify() + ".");
|
||||||
|
};
|
||||||
|
|
||||||
|
var notice1 = new Notice("应用A");
|
||||||
|
var notice2 = new Notice("应用B");
|
||||||
|
|
||||||
|
Object.getPrototypeOf(notice1) === Notice.prototype //true
|
||||||
|
Object.getPrototypeOf(notice2) === Notice.prototype //true
|
||||||
|
|
||||||
|
notice1.display(); // "你好,这是应用A"
|
||||||
|
notice2.display(); // "你好,这是应用B"
|
||||||
|
|
||||||
|
|
||||||
|
而这就印证了前面所说的,在传统的面向对象语言,比如Java里,当我们用到继承时,一个类的属性和功能是可以被基于这个类创建的对象“拷贝”过去的。但是在JavaScript里,虽然我们用Notice创建了notice1和notice2,但是它俩并没有将其属性和功能拷贝过来,而是默认通过原型链来寻找原型中的功能,然后利用“链接”而不是“拷贝”来。
|
||||||
|
|
||||||
|
for (var method in Notice.prototype) {
|
||||||
|
console.log("found: " + method);
|
||||||
|
}
|
||||||
|
// found: display
|
||||||
|
// found: identify
|
||||||
|
|
||||||
|
|
||||||
|
所以,我们通过上面的for in,就可以找出所有原型链上的功能,而如果我们想要看Notice函数的原型对象都有哪些功能的话,可以看到返回的是display和identify。这就证明,除了Notice自己的原型对象和自己的display功能之外,它也链接了Widget里的identify功能。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
好,现在我们知道了,面向对象编程最核心的点就是服务业务对象,最需要解决的问题就是封装、重用和继承。在JavaScript中,面向对象的特殊性是基于原型链的继承,这种继承更像是“授权”,而不是传统意义的“父子”继承。而且为了解决继承的层级过多的情况,在面向对象中,也有组合优于继承的思想。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这节课,我们主要是了解了函数式编程和面向对象编程的核心概念,它们一个是管理和解决副作用,一个是服务于业务对象。
|
||||||
|
|
||||||
|
而理解这部分内容,对于我们接下来要深入了解的这两种编程模式,以及后面会学习的各种数据结构和算法、JavaScript的各种设计模式等等,都有很强的指导意义,它能为我们学好并更好地应用JavaScript这门语言,提供扎实的理论基础。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
我们提到函数式编程的时候,说到为了解决副作用,因此有了不可变和纯函数的概念,那么你觉得JavaScript中的常量(const,constant)算不算不可变呢?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||||
|
|
||||||
|
延伸阅读
|
||||||
|
|
||||||
|
|
||||||
|
Beginning Functional JavaScript
|
||||||
|
Function Light JS
|
||||||
|
JavaScript世界的一等公民——函数
|
||||||
|
Mastering JavaScript Functional Programming
|
||||||
|
You Don’t Know JS: this & Object Prototypes
|
||||||
|
JavaScript Patterns
|
||||||
|
Learning JavaScript Design Patterns
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
269
专栏/JavaScript进阶实战课/02如何通过闭包对象管理程序中状态的变化?.md
Normal file
269
专栏/JavaScript进阶实战课/02如何通过闭包对象管理程序中状态的变化?.md
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
02 如何通过闭包对象管理程序中状态的变化?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
通过上节课的学习,现在我们知道,函数式编程中存在副作用(side effect),而纯函数和不可变就是减少副作用的两个核心思想。那么按理说,我们要想把副作用降低到接近为零,就可以用纯函数,同时不接受任何参数。但是这样完全自我封闭的函数,也就几乎没有什么使用意义了。
|
||||||
|
|
||||||
|
所以,作为一个函数,还是要有输入、计算和输出,才能和外界有互动往来,我们的系统也才能“活”起来。而一个活的系统,它的状态肯定是在不停变化的,那么我们如何才能在不可变的原则下,来管理这种变化呢?
|
||||||
|
|
||||||
|
今天这节课,我们就一起来看看在函数式编程中,有哪些值是可变的、哪些不可变,以及如何能在状态更新的同时做到不可变。
|
||||||
|
|
||||||
|
值的(不)可变
|
||||||
|
|
||||||
|
首先,我们要搞清楚一个问题,值到底是不是可变的?在JavaScript中,值一般被分为两种:原始类型和对象类型。
|
||||||
|
|
||||||
|
先来看原始类型。像字符串或数字这种数据类型,都是属于原始类型,而它们本身是不可变的。举个例子:在console.log中输入2 = 2.5,得到的结果会是invalid,这就证明了我们不可能改变一个原始类型的值。
|
||||||
|
|
||||||
|
2 = 2.5 // invalid
|
||||||
|
|
||||||
|
|
||||||
|
然后是对象类型。在JavaScript中,像数组、对象这类数据类型就叫做对象类型,这类数据更像是一种数据结构或容器。那这样的“值”是否可变?其实通过上节课数组的例子,你能看到这类值是可变的,比如通过splice这种方法。
|
||||||
|
|
||||||
|
所以下面,我们就来看看在使用对象类型的值来存储状态的时候,要如何在更新状态的同时做到不可变。
|
||||||
|
|
||||||
|
React.js中的props和state
|
||||||
|
|
||||||
|
这里,我们就以React.js为例,来观察下它是用什么类型的值作为状态的。
|
||||||
|
|
||||||
|
说到状态,React中有两个很重要的概念经常容易被混淆,分别是props和state。props通常是作为一个外部参数传入到函数里,然后作为静态元素输出在UI中渲染;state则是一个内部变量,它会作为动态元素输出在UI中渲染,并根据行为更新状态。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在上面这个图例中,有一个静态的文案和一个动态的计数器。其中,props就是“点击增加:”这个文案,它在页面上基本是不应该有什么变化的,就是一句固定的提示语,所以它就是props,一个静态的“属性”。
|
||||||
|
|
||||||
|
而在计数按钮中,它的值是基于每次点击加一的,也就是基于点击动态变化的,所以我们说它就是state,一个动态“状态”。
|
||||||
|
|
||||||
|
// 属性 props
|
||||||
|
class Instruction extends React.Component {
|
||||||
|
render() {
|
||||||
|
return <span>提示 - {this.props.message}</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const element = <Instruction message="点击以下按钮增加:" />;
|
||||||
|
|
||||||
|
// 状态 state
|
||||||
|
class Button extends React.Component {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.state = {
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
updateCount() {}
|
||||||
|
render() {
|
||||||
|
return (<button onClick={() => this.updateCount()}> 点击了 {this.state.count} 次</button>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
那么回到刚才的问题,在React.js里,props和state是什么类型的值呢?答案是对象,props和state都是用对象来储存状态的。
|
||||||
|
|
||||||
|
可是,React为什么用对象做属性和状态存储值类型呢?它还有没有其它选择呢?下面我们就来看看。
|
||||||
|
|
||||||
|
结构型值的不可变
|
||||||
|
|
||||||
|
我们先来思考一个问题:props和state是不是必须的?
|
||||||
|
|
||||||
|
答案是,props是必须的,而state不是。因为假设我们的页面是一个静态页面,那么我们渲染内容就好了,但这种纯静态的应用也完全无法和用户互动。
|
||||||
|
|
||||||
|
所以在前端,纯静态的应用被叫做dumb as f*ck。当然这是一个不太文明的词,不过话糙理不糙,而且它的意思也很明显,就是说这样的应用也太“笨”了。
|
||||||
|
|
||||||
|
我们的应用肯定需要和用户交互,而一旦有交互,我们就需要管理值的状态(state) 和围绕值设计一系列行为(behavior)。在这个过程中,我们需要考虑的就是一个值的结构性不可变的问题。
|
||||||
|
|
||||||
|
所以接下来,我们就一起看看围绕值的结构性操作,都有哪些数据类型可以选择。
|
||||||
|
|
||||||
|
闭包和对象
|
||||||
|
|
||||||
|
首先是闭包(closure)和对象(object),这二者都可以对一个状态值进行封装和创建行为。
|
||||||
|
|
||||||
|
闭包最大的特点是可以突破生命周期和作用域的限制,也就是时间和空间的控制。
|
||||||
|
|
||||||
|
这里的突破生命周期的限制是指,当一个外部函数内嵌一个内部函数时,如果内嵌函数引用了外部函数的变量,这个变量就会突破生命周期的限制,在函数结束执行后,仍然存在。比如在下面闭包的例子中,我们创建了一个计数器,每次加1,可以记住上一次的值。
|
||||||
|
|
||||||
|
而突破作用域的限制是指,我们可以把一个内部函数返回成一个方法在外部调用。比如以下代码中,counter返回的counting方法,我们可以通过counter1来执行这个方法,从而就突破了counter作用域的限制。
|
||||||
|
|
||||||
|
function counter() {
|
||||||
|
let name = "计数";
|
||||||
|
let curVal = 0;
|
||||||
|
function counting() {
|
||||||
|
curVal++;
|
||||||
|
}
|
||||||
|
function getCount() {
|
||||||
|
console.log(
|
||||||
|
`${name}是${curVal}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {counting,getCount}
|
||||||
|
}
|
||||||
|
|
||||||
|
var counter1 = counter();
|
||||||
|
|
||||||
|
counter1.counting();
|
||||||
|
counter1.counting();
|
||||||
|
counter1.counting();
|
||||||
|
counter1.getCount(); // 计数是3
|
||||||
|
|
||||||
|
|
||||||
|
同样地,我们也可以通过对象来封装一个状态,并且创建一个方法来作用于这个状态值。
|
||||||
|
|
||||||
|
var counter = {
|
||||||
|
name: "计数",
|
||||||
|
curVal: 0,
|
||||||
|
counting() {
|
||||||
|
this.curVal++;
|
||||||
|
console.log(
|
||||||
|
`${this.name}是${this.curVal}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
counter.counting(); // 计数是1
|
||||||
|
counter.counting(); // 计数是2
|
||||||
|
counter.counting(); // 计数是3
|
||||||
|
|
||||||
|
|
||||||
|
所以,单纯从值的状态管理和围绕它的一系列行为的角度来看,我们可以说闭包和对象是同形态的(isomorphic),也就是说可以起到异曲同工的作用。比如上面闭包例子中的状态,就是对象中的属性,我们在闭包中创建的针对值的行为,也可以在对象中通过方法来实现。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
你可能要问,我们对比它们的意义是什么呢?其实是因为它们在隐私(privacy)、状态拷贝(state cloning)和性能(performance)上。还是有差别的,而这些差别在结构性地处理值的问题上,具有不同的优劣势。
|
||||||
|
|
||||||
|
下面,我们就从这几个维度的不同点展开来看下。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
属性的查改
|
||||||
|
|
||||||
|
实际上,你通过闭包的例子可以发现,除非是通过接口,也就是在外部函数中返回内部函数的方法,比如用来获取值的getCount方法,或者重新赋值的counting方法,不然内部的值是对外不可见的。
|
||||||
|
|
||||||
|
所以,它其实可以细粒度地控制我们想要暴露或隐藏的属性,以及相关的操作。
|
||||||
|
|
||||||
|
counter1.counting();
|
||||||
|
counter1.getCount();
|
||||||
|
|
||||||
|
|
||||||
|
而对象则不同,我们不需要特殊的方式,就可以获取对象中的属性和重新赋值。如果想要遵循不可变的原则,有一个 Object.freeze() 的方法,可以把所有的对象设置成只读 writable: false。
|
||||||
|
|
||||||
|
但这里有一点需要注意,通过freeze会让对象所有的属性变得只读,而且不可逆。当然,它的好处就是严格遵守了不可变原则。
|
||||||
|
|
||||||
|
counter.name;
|
||||||
|
counter.initVal;
|
||||||
|
counter.counting();
|
||||||
|
|
||||||
|
|
||||||
|
状态的拷贝
|
||||||
|
|
||||||
|
所以到这里,我们可以发现,针对原始类型的数据,无需过度担忧值的不可变。
|
||||||
|
|
||||||
|
不过,既然应用是“活”的,就可能会有“一系列”状态。我们通常需要通过诸如数组、对象类的数据结构,来保存“一系列”状态,那么在面对这一类的数据时,我们如何做到遵循不可变的原则呢?
|
||||||
|
|
||||||
|
|
||||||
|
如何通过拷贝管理状态?
|
||||||
|
|
||||||
|
|
||||||
|
要解决这个问题,我们可以通过拷贝+更新的方式。也就是说,我们不对原始的对象和数组值做改变,而是拷贝之后,在拷贝的版本上做变更。
|
||||||
|
|
||||||
|
比如在下面的例子中,我们通过使用spread,来展开数组和对象中的元素,然后再把元素赋值给新的变量,通过这种方式,我们完成了浅拷贝。之后,我们再看数组和对象的时候,会发现原始的数据不会有变化。
|
||||||
|
|
||||||
|
// 数组浅拷贝
|
||||||
|
var a = [ 1, 2 ];
|
||||||
|
var b = [ ...a ];
|
||||||
|
b.push( 3 );
|
||||||
|
a; // [1,2]
|
||||||
|
b; // [1,2,3]
|
||||||
|
|
||||||
|
// 对象浅拷贝
|
||||||
|
var o = {
|
||||||
|
x: 1,
|
||||||
|
y: 2
|
||||||
|
};
|
||||||
|
var p = { ...o };
|
||||||
|
p.y = 3;
|
||||||
|
o.y; // 2
|
||||||
|
p.y; // 3
|
||||||
|
|
||||||
|
|
||||||
|
所以可见,数组和对象都是很容易拷贝的,而闭包则相对更难拷贝。
|
||||||
|
|
||||||
|
|
||||||
|
如何解决拷贝性能问题?
|
||||||
|
|
||||||
|
|
||||||
|
从上面的例子中,我们可以看到通过对状态的拷贝,是可以做到不可变,不过随之而来的就是性能问题。
|
||||||
|
|
||||||
|
如果这个值只改变一两次,那就没问题。但假设我们的系统中有值不停在改变,如果每次都拷贝的话,就会占据大量内存。这样一来,我们应该如何处理呢?
|
||||||
|
|
||||||
|
实际上,在这种情况下,有一个解决方案就是用到一个类似链表的结构,当中有一组对象记录改变的指数和相关的值。
|
||||||
|
|
||||||
|
比如下面的 [3, 1, 0, 7] 这组数组中,我们把第0个值变成2,第3个值变成6,第4个值添加1,形成了 [2, 1, 0, 6, 1]。那么如果我们只记录变化的话,就是0:2、3:6和4:1这三组对象,是不是就减少了很多内存占用?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
其实目前在市面上,已经有很多成熟的三方库比如immutable.js,它们会有自己的数据结构,比如array list和object map,以及相关算法来解决类似的问题了。
|
||||||
|
|
||||||
|
性能的考虑
|
||||||
|
|
||||||
|
我们接着再来看看性能上的考虑。
|
||||||
|
|
||||||
|
从性能的角度来讲,对象的内存和运算通常要优于闭包。比如,在下面第一个闭包的例子中,我们每次使用都会创建一个新的函数表达。
|
||||||
|
|
||||||
|
而第二个对象的例子中,我们通过bind将this绑定到greetings2上,这样一来,PrintMessageB就会引用greetings2.name来作为this.name,从而达到和闭包一样的效果。但我们不需要创建一个闭包,只需要将this指向引用的对象即可。
|
||||||
|
|
||||||
|
// 闭包
|
||||||
|
function PrintMessageA(name) {
|
||||||
|
return function printName(){
|
||||||
|
return `${name}, 你好!`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var greetings1 = PrintMessageA( "先生" );
|
||||||
|
greetings1(); // 先生,你好!
|
||||||
|
|
||||||
|
// 对象
|
||||||
|
function PrintMessageB(){
|
||||||
|
return `${this.name}, 你好!`;
|
||||||
|
}
|
||||||
|
var greetings2 = PrintMessageB.bind( {
|
||||||
|
name: "先生"
|
||||||
|
} );
|
||||||
|
greetings2(); // 先生,你好!
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这节课,我们一起深入理解了函数式编程中的不可变。我们需要重点关注的,就是对象和闭包在处理不可变问题上的不同优势。
|
||||||
|
|
||||||
|
|
||||||
|
在属性和方法的隐私方面,闭包天然对属性有保护作用,同时它也可以按需暴露接口,来更细粒度地获取或重新给状态赋值。但是它和我们要解决的问题,似乎关系不大。
|
||||||
|
而对象不仅可以轻松做到 props整体不可变,而且在需要state变化时,在拷贝上也更有优势。不过从性能的角度来看,如果拷贝的量不大,也许它们的性能差不多,但如果是一个高频交互的界面,微小的差别可能就会被放大。
|
||||||
|
|
||||||
|
|
||||||
|
所以总结起来,在React.js中,它选择使用对象作为props和state的值类型,能更容易保证属性和状态值的整体不可变;而且面对状态的变化,它也更容易拷贝;在处理高频交互时,它的性能也会更好。
|
||||||
|
|
||||||
|
而闭包虽然有隐私上的优势和更细粒度的操作,可是在应用交互和状态管理这个场景下,它并没有什么实际的作用。所以,有利于使用对象的条件会相对多一些。
|
||||||
|
|
||||||
|
最后,你也可以再来复习下这两种方式的优劣势。其实,基于React.js的例子,你可以发现,不同的数据类型和处理变与不变的方式,并没有绝对的好与不好,而是需要根据具体情况,来确定哪种方式更适合你的程序和应用所需要支持的场景。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
我们在提到值的状态拷贝时,说spread做到的是浅拷贝,那么你是否了解与之对应的深度拷贝?它会不会影响状态的管理?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的思考和答案,也欢迎你把今天的内容分享给更多的朋友。
|
||||||
|
|
||||||
|
延伸阅读
|
||||||
|
|
||||||
|
|
||||||
|
Component State - React
|
||||||
|
Props vs State
|
||||||
|
ReactJS: Props vs. State
|
||||||
|
Closure vs. Object
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
245
专栏/JavaScript进阶实战课/03如何通过部分应用和柯里化让函数具象化?.md
Normal file
245
专栏/JavaScript进阶实战课/03如何通过部分应用和柯里化让函数具象化?.md
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
03 如何通过部分应用和柯里化让函数具象化?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在前面两节课里,我说过函数式编程的核心就是把数据作为输入,通过算法进行计算,最后输出结果。同时我也提到,在函数式+响应式编程中,面对未知、动态和不可控时,可以通过纯函数和不可变等手段减少副作用、增加确定性,及时地适应和调整。
|
||||||
|
|
||||||
|
那么现在你来想想,在输入、计算和输出这个过程中,什么地方是最难控制的呢?对,就是输入。因为它来自外界,而计算是在相对封闭的环境中,至于输出只是一个结果。
|
||||||
|
|
||||||
|
所以今天这节课,我们就来说说输入的控制。
|
||||||
|
|
||||||
|
部分应用和柯里化
|
||||||
|
|
||||||
|
在前面课程里也讲过,函数的输入来自参数,其中包括了函数定义时的形参和实际执行时的实参。另外,我们也通过React.js中的props和state以及JavaScript中的对象和闭包,具体了解了如何通过不可变,做到对运行时的未知状态变化的管理。
|
||||||
|
|
||||||
|
那今天,我们就从另外一个角度理解下对编程时“未知”的处理,即如果我们在编写一个函数时,需要传入多个实参,其中一部分实参是先明确的,另一部分是后明确的,那么该如何处理呢?
|
||||||
|
|
||||||
|
其实就是部分应用(partial application)和柯里化(currying)。下面我们就一起来看看函数式编程中,如何突破在调用点(call-site)传参的限制,做到部分传参和后续执行。
|
||||||
|
|
||||||
|
通过部分应用延迟实参传入
|
||||||
|
|
||||||
|
我们知道,函数式编程重在声明式和可读性,而且强调每个函数尽量解决一个单一问题。假设有一个orderEventHandler函数,它比较抽象,因此缺少可读性;又或者假设下面这个函数需要url、data和callback三个参数的输入,才能执行,我们预先知道它的url,却不知道它的data和callback。这时该怎么办呢?
|
||||||
|
|
||||||
|
function orderEventHandler(url,data,callback) {
|
||||||
|
// ..
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
要解决这些问题,我们就可以通过部分应用。下面是它的一个执行流程图。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
也就是说,我们可以通过orderEventHandler函数,具象出一个专门的fetchOrder函数。通过这种方式,我们就提前预置了已知参数url,减少了后面需要传入的参数数量,同时也增加了代码的可读性。
|
||||||
|
|
||||||
|
function fetchOrder(data,cb) {
|
||||||
|
orderEventHandler( "http://some.api/order", data, cb );
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
可是如果我们想进一步具象化,预制一些参数怎么办?比如下面的getCurrentOrder,如果我们想把前面fetchOrder里的data,也内置成order: CURRENT_ORDER_ID,这样会大量增加代码结构的复杂性。
|
||||||
|
|
||||||
|
function getCurrentOrder(cb) {
|
||||||
|
getCurrentOrder( { order: CURRENT_ORDER_ID }, cb );
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
所以在函数式编程中,我们通常会使用部分应用。它所做的就是抽象一个partial工具,在先预制部分参数的情况下,后续再传入剩余的参数值。如以下代码所示:
|
||||||
|
|
||||||
|
var fetchOrder = partial( orderEventHandler, "http://some.api/order" );
|
||||||
|
var getCurrentOrder = partial( fetchOrder, { order: CURRENT_ORDER_ID } );
|
||||||
|
|
||||||
|
|
||||||
|
partial工具可以借助我们在上节课提到过的闭包,以及ES6中引入的…延展操作符(spread operator)这两个函数式编程中的利器来实现。
|
||||||
|
|
||||||
|
我先来说一下延展操作符,它的强大之处就是可以在函数调用或数组构造时,将数组表达式或者string在语法层面展开。在这里,我们可以用它来处理预置的和后置的实参。而闭包在这里再次发挥了记忆的功能,它会记住前置的参数,并在下一次收到后置的参数时,可以和前面记住的前置参数一起执行。
|
||||||
|
|
||||||
|
var partial =
|
||||||
|
(fn,...presetArgs) =>
|
||||||
|
(...laterArgs) =>
|
||||||
|
fn( ...presetArgs, ...laterArgs );
|
||||||
|
|
||||||
|
|
||||||
|
除此之外,我们在上一讲里提到的bind也可以起到类似的作用。但 bind通常是在面向对象中用来绑定this的,用作部分应用的话会相对比较奇怪,因为这里我们不绑定this,所以第一个参数我们会设置为null。
|
||||||
|
|
||||||
|
当然,这么用确实不会有什么问题,但是一般来说,为了不混淆bind的使用场景,我们最好还是用自己定义的partial工具。
|
||||||
|
|
||||||
|
var fetchOrder = httpEvntHandler.bind( null, "http://some.api/order" );
|
||||||
|
|
||||||
|
|
||||||
|
通过柯里化每次传一个参数
|
||||||
|
|
||||||
|
我们接着来看看柯里化。
|
||||||
|
|
||||||
|
可以看到,在下面的例子中,我们把之前的httpEventHandler做了柯里化处理之后,就不需要一次输入3个参数了,而是每次只传入一个参数。第一次,我们传入了url来获取订单;之后,我们传入了当前订单的id;最后,我们获得了当前订单后,传入一个订单修改的参数来做相关修改。
|
||||||
|
|
||||||
|
var curriedOrderEvntHandler = curry( orderEventHandler );
|
||||||
|
|
||||||
|
var fetchOrder = curriedHttpEvntHandler( "http://some.api/order" );
|
||||||
|
|
||||||
|
var getCurrentOrder = fetchOrder( { order: CURRENT_ORDER_ID } );
|
||||||
|
|
||||||
|
getCurrentOrder( function editOrder(order){ /* .. */ } );
|
||||||
|
|
||||||
|
|
||||||
|
你同样可以来看一下它的一个执行流程图,看看柯里化是如何实现的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
实际上,和部分应用类似,这里我们也用到了闭包和…延展操作符。
|
||||||
|
|
||||||
|
在柯里化中,延展操作符可以在函数调用链中起到承上启下的作用。当然,市面上实现部分应用和柯里化的方式有很多,这里我选了一个“可读性”比较高的。因为和部分应用一样,它有效说明了参数前后的关系。
|
||||||
|
|
||||||
|
function curry(fn,arity = fn.length) {
|
||||||
|
return (function nextCurried(prevArgs){
|
||||||
|
return function curried(nextArg){
|
||||||
|
var args = [ ...prevArgs, nextArg ];
|
||||||
|
if (args.length >= arity) {
|
||||||
|
return fn( ...args );
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return nextCurried( args );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})( [] );
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
好了,通过部分应用和柯里化的例子,我们能够发现函数式编程处理未知的能力。但这里我还想强调一点,这个未知,跟我们说的应用在运行时的未知是不同的。这里的未知指的是编程时的未知,比如有些参数是我们提前知道的,而有一些是后面加入的。
|
||||||
|
|
||||||
|
要知道,一个普通的函数通常是在调用点执行时传入参数的,而通过部分应用和柯里化,我们做到了可以先传入部分已知参数,再在之后的某个时间传入部分参数,这样从时间和空间上,就将一个函数分开了。
|
||||||
|
|
||||||
|
而这样做除了一些实际的好处,比如处理未知,让函数从抽象变具体、让具体的函数每次只专心做好一件事、减少参数数量之外,还有一个更抽象的好处,就是体现了函数式底层的声明式思想。
|
||||||
|
|
||||||
|
在这里,我们让代码变得更可读。
|
||||||
|
|
||||||
|
还有哪些常用的参数处理工具?
|
||||||
|
|
||||||
|
在函数式编程中,我们把参数的数量叫做 arity。从上面的例子中,我们可以看到,部分应用可以减少每次函数调用时需要传入的参数,而柯里化更是把函数调用时需要传入的参数数量,降到了1。它们实际上都起到了控制参数数量的作用。
|
||||||
|
|
||||||
|
而在函数式编程中,其实还有很多可以帮助我们处理参数输入的工具。下面,我们就通过unary、constant和identity这几个简单的例子来一起看看。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
改造接口unary
|
||||||
|
|
||||||
|
我们先来看看改造函数的工具。其中,最简单的工具就是一元参数(unary)了,它的作用是把一个接收多个参数的函数,变成一个只接收一个参数的函数。其实现也很简单:
|
||||||
|
|
||||||
|
function unary(fn) {
|
||||||
|
return function oneArg(arg){
|
||||||
|
return fn( arg );
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
你可能会问它有什么用?我来举个例子。
|
||||||
|
|
||||||
|
当你想通过parseInt,把一组字符串通过map来映射成整数,但是parseInt会接收两个参数,而如果你直接输入parseInt的话,那么“2”就会成为它的第二个参数,这肯定不是你期待的结果吧。
|
||||||
|
|
||||||
|
所以这时候,unary就派上用场了,它可以让parseInt只接收一个参数,从而就可以正确地打出你想要的结果。
|
||||||
|
|
||||||
|
["1","2","3","4","5"].map( unary( parseInt ) ); // [1,2,3,4,5]
|
||||||
|
|
||||||
|
|
||||||
|
看到这里,聪明的你可能会问:除了一元,会不会有二元、三元?答案是有的。二元就是binary,或是函数式中的“黑话”dyadic;三元就是tenary。顾名思义,它们分别代表的就是把一个函数的参数数量控制在2个和3个。
|
||||||
|
|
||||||
|
改造参数constant
|
||||||
|
|
||||||
|
如果你用过JavaScript promise的话,应该对then不陌生。从函数签名的角度来看,它只接收函数,而不接收其他值类型作为参数。比如下面例子中,34这个值就是不能被接收的。
|
||||||
|
|
||||||
|
promise1.then( action1 ).then( 34 ).then( action3 );
|
||||||
|
|
||||||
|
|
||||||
|
这里你可能会问,什么是函数签名?函数签名一般包含了参数及其类型返回值,还有类型可能引发或传回的异常,以及相关的方法在面向对象中的可用性信息(如关键字public、static或prototype)。你可以看到在C或C++中,会有类似这样的签名,如下所示:
|
||||||
|
|
||||||
|
// C
|
||||||
|
int main (int arga, char *argb[]) {}
|
||||||
|
|
||||||
|
// C++
|
||||||
|
int main (int argc, char **argv) {/** ... **/ }
|
||||||
|
|
||||||
|
|
||||||
|
而在JavaScript当中,基于它“放荡不羁”的特性,就没有那么多条条框框了,甚至连命名函数本身都不是必须的,就更不用说签名了。那么遇到then这种情况怎么办呢?
|
||||||
|
|
||||||
|
在这种情况下,我们其实可以编写一个只返回值的constant函数,这样就解决了接收的问题。由此也能看出,JavaScript在面对各种条条框框的时候,总是上有政策下有对策。
|
||||||
|
|
||||||
|
function constant(v) {
|
||||||
|
return function value(){
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
然后,我们就可以把值包装在constant函数里,通过这样的方式,就可以把值作为函数参数传入了。
|
||||||
|
|
||||||
|
promise1.then( action1 ).then( constant( 34 ) ).then( action3 );
|
||||||
|
|
||||||
|
|
||||||
|
不做改造identity
|
||||||
|
|
||||||
|
还有一个函数式编程中常用的工具,也就是identity,它既不改变函数,也不改变参数。它的功能就是输入一个值,返回一个同样的值。你可能会觉着,这有啥用?
|
||||||
|
|
||||||
|
function identity(v) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
其实它的作用也很多。比如在下面的例子中,它可以作为断言(predicate), 来过滤掉空值。在函数式编程中,断言是一个可以用来做判断条件的函数,在这个例子里,identity就作为判断一个值是否为空的断言。
|
||||||
|
|
||||||
|
var words = " hello world ".split( /\s|\b/ );
|
||||||
|
words; // ['', '', '', 'hello', 'world', '', '']
|
||||||
|
|
||||||
|
words.filter( identity ); // ['hello', 'world']
|
||||||
|
|
||||||
|
|
||||||
|
当然,identity的功能也不止于此,它也可以用来做默认的转化工具。比如以下例子中,我们创建了一个transLogger函数,可以传入一个实际的数据和相关的lower功能函数,来将文字转化成小写。
|
||||||
|
|
||||||
|
function transLogger (msg,formatFn = identity) {
|
||||||
|
msg = formatFn( msg );
|
||||||
|
console.log( msg );
|
||||||
|
}
|
||||||
|
|
||||||
|
function lower(txt) {
|
||||||
|
return txt.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
transLogger( "Hello World" ); // Hello World
|
||||||
|
transLogger( "Hello World", lower ); // hello world
|
||||||
|
|
||||||
|
|
||||||
|
除了以上这些工具以外,还有更复杂一些的工具来解决参数问题。比如在讲部分应用和柯里化的时候,提到它在给我们带来一些灵活性的同时,也仍然会有一些限制,即参数的顺序问题,我们必须按照一个顺序来执行。而有些三方库提供的一些工具,就可以将参数倒排或重新排序。
|
||||||
|
|
||||||
|
重新排序的方式有很多,可以通过解构(destructure),从数组和对象参数中提取值,对变量进行赋值时重新排序;或通过延展操作符把一个对象中的一组值,“延展”成单独的参数来处理;又或者通过 .toString() 和正则表达式解析函数中的参数做处理。
|
||||||
|
|
||||||
|
但是,有时我们在灵活变通中也要适度,不能让它一不小心变成了“奇技淫巧”,所以对于类似“重新排序”这样的技巧,在课程中我就不展开了,感兴趣的话你可以在延伸阅读部分去深入了解。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过今天这节课,我们能看到在面对未知、动态和不可控时,函数式编程很重要的一点就是控制好输入。
|
||||||
|
|
||||||
|
在课程里,我们一起重点了解了函数的输入中的参数,知道部分应用和柯里化,可以让代码更好地处理编程中的未知,让函数从抽象变具体,让具体的函数每次只专心做好一件事,以及可以在减少参数的数量之外,还能够增加可读性。
|
||||||
|
|
||||||
|
另外,我们也学习了更多“个子小,功能大”的工具,我们不仅可以通过这些工具,比如unary和constant来改造函数和参数,从而解决适配问题;同时,哪怕是看上去似乎只是在“透传”值的identity,实际上都可以用于断言和转化。而这样做的好处,就是可以尽量提高接口的适应性和适配性,增加过滤和转化的能力,以及增加代码的可读性。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
今天我们主要学习了柯里化,而与它相反的就是反柯里化(uncurry),那么你知道反柯里化的用途和实现吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。
|
||||||
|
|
||||||
|
延伸阅读
|
||||||
|
|
||||||
|
|
||||||
|
JS 函数签名
|
||||||
|
C/C++ 函数签名与名字修饰(符号修饰)
|
||||||
|
JS 中的解构
|
||||||
|
Functional Light JS
|
||||||
|
JavaScript Patterns - Chapter 4 Functions
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
231
专栏/JavaScript进阶实战课/04如何通过组合、管道和reducer让函数抽象化?.md
Normal file
231
专栏/JavaScript进阶实战课/04如何通过组合、管道和reducer让函数抽象化?.md
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
04 如何通过组合、管道和reducer让函数抽象化?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
上节课我们讲到,通过部分应用和柯里化,我们做到了从抽象到具象化。那么,今天我们要讲的组合和管道,就是反过来帮助我们把函数从具象化变到抽象化的过程。它相当于是系统化地把不同的组件函数,封装在了只有一个入口和出口的函数当中。
|
||||||
|
|
||||||
|
其实,我们在上节课讲处理函数输入问题的时候,在介绍unary的相关例子中,已经看到了组合的雏形。在函数式编程里,组合(Composition)的概念就是把组件函数组合起来,形成一个新的函数。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
我们可以先来看个简单的组合函数例子,比如要创建一个“判断一个数是否为奇数”的isOdd函数,可以先写一个“计算目标数值除以2的余数”的函数,然后再写一个“看结果是不是等于1”的函数。这样,isOdd函数就是建立在两个组件函数的基础上。
|
||||||
|
|
||||||
|
var isOdd = compose(equalsToOne, remainderOfTwo);
|
||||||
|
|
||||||
|
|
||||||
|
不过,你会看到这个组合的顺序是反直觉的,因为如果按照正常的顺序,应该是先把remainderByTwo放在前面来计算余数,然后再执行后面的equalsToOne, 看结果是不是等于1。
|
||||||
|
|
||||||
|
那么,这里为什么会有一个反直觉的设计呢?今天这节课,我们就通过回答这个问题,来看看组合和管道要如何做到抽象化,而reducer又是如何在一系列的操作中,提高针对值的处理性能的。
|
||||||
|
|
||||||
|
组合Compose
|
||||||
|
|
||||||
|
在讲组合前,我想先带你来看看Point-Free和函数组件。这里,我们还是用刚刚提到的“判断一个值是不是奇数”的isOdd函数,来一步步看下它的实现。
|
||||||
|
|
||||||
|
Point-Free
|
||||||
|
|
||||||
|
那么首先,什么是Point-Free呢?实际上,Point-Pree是函数式编程中的一种编程风格,其中的Point是指参数,free是指没有。加在一起,Point-Free的意思就是没有参数的函数。
|
||||||
|
|
||||||
|
而这样做的目的是什么呢?其实通过这种方式,就可以将一个函数和另外一个函数结合起来,形成一个新函数。比如,为了要创建isOdd函数,通过这种方式,我们就可以把这两个函数“组合”在一起,得到isOdd。
|
||||||
|
|
||||||
|
var isOdd = (x) => equalsToOne(remainderOfTwo(x));
|
||||||
|
|
||||||
|
|
||||||
|
函数组件
|
||||||
|
|
||||||
|
接着,我们再来看函数组件。
|
||||||
|
|
||||||
|
在以下的代码示例当中,我们先定义了两个函数:第一个是dividedBy,它的作用是计算x除以y的余数;第二个是equalsTo,它是用来看余数是否等于1。
|
||||||
|
|
||||||
|
这两个函数其实就是我们用到的组件函数。你可以发现,这两个组件的特点都是努力专注做好一件小事。
|
||||||
|
|
||||||
|
var dividedBy = (y) => {
|
||||||
|
return function forX(x) {
|
||||||
|
return x % y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var equalsTo = (y) => {
|
||||||
|
return function forX(x) {
|
||||||
|
return x === y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
然后,在dividedBy和equalsToOne的基础上,我们就可以创建两个Point-Free的函数,remainderOfTwo和equalsToOne。
|
||||||
|
|
||||||
|
var remainderOfTwo = dividedBy(2);
|
||||||
|
var equalsToOne = equalsTo(1);
|
||||||
|
|
||||||
|
|
||||||
|
最后,我们只需要传入参数 x,就可以计算相应的isOdd的结果了。
|
||||||
|
|
||||||
|
var isOdd = (x) => equalsToOne(remainderOfTwo(x));
|
||||||
|
|
||||||
|
|
||||||
|
好了,现在我们知道了,函数是可以通过写成组件来应用的。这里其实就是用到了函数式编程声明式的思想,equalsToOne和remainderByTwo,不仅把过程进行了封装,而且把参数也去掉了,暴露给使用者的就是功能本身。所以,我们只需要把这两个函数组件的功能结合起来,就可以实现isOdd函数了。
|
||||||
|
|
||||||
|
独立的组合函数
|
||||||
|
|
||||||
|
下面我们再来看看独立的组合函数。
|
||||||
|
|
||||||
|
其实从上面的例子里,我们已经看到了组合的影子。那么更进一步地,我们就可以把组合抽象成一个独立的函数,如下所示:
|
||||||
|
|
||||||
|
function compose(...fns) {
|
||||||
|
return fns.reverse().reduce( function reducer(fn1,fn2){
|
||||||
|
return function composed(...args){
|
||||||
|
return fn2( fn1( ...args ) );
|
||||||
|
};
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
也就是说,基于这里抽象出来的compose功能,我们可以把之前的组件函数组合起来。
|
||||||
|
|
||||||
|
var isOdd = compose(equalsToOne, remainderOfTwo);
|
||||||
|
|
||||||
|
|
||||||
|
所以,回到课程一开始提到的问题:为什么组合是反直觉的?因为它是按照传参顺序来排列的。
|
||||||
|
|
||||||
|
前面讲的这个组合,其实就是 equalsToOne(remainderOfTwo(x))。在数学中,组合写成 fog,意思就是一个函数接收一个参数x,并返回成一个 f(g(x))。
|
||||||
|
|
||||||
|
好,不过看到这里,你可能还是觉得,即使自己理解了它的概念,但是仍然觉得它反直觉,因此想要一种更直观的顺序来完成一系列操作。这个也有相应的解决方案,那就是用函数式编程中的管道。
|
||||||
|
|
||||||
|
管道Pipeline
|
||||||
|
|
||||||
|
函数式编程中的管道,是另外一种函数的创建方式。这样创建出来的函数的特点是:一个函数的输出会作为下一个函数的输入,然后按顺序执行。
|
||||||
|
|
||||||
|
所以,管道就是以组合反过来的方式来处理的。
|
||||||
|
|
||||||
|
Unix/Linux中的管道
|
||||||
|
|
||||||
|
其实管道的概念最早是源于Unix/Linux,这个概念的创始人道格拉斯·麦克罗伊(Douglas McIlroy)在贝尔实验室的文章中,曾经提到过两个很重要的点:
|
||||||
|
|
||||||
|
|
||||||
|
一是让每个程序只专注做好一件事。如果有其它新的任务,那么应该重新构建,而不是通过添加新功能使旧程序复杂化。
|
||||||
|
二是让每个程序的输出,可以成为另一个程序的输入。
|
||||||
|
|
||||||
|
|
||||||
|
感兴趣的话你也可以读一下这篇杂志文章,虽然这是1978年的文章,但是它的设计思想到现在都不算过时。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
好,那么现在,我们就来看一个简单的管道例子,在这个例子里,我们可以找到当前目录下面所有的JavaScript文件。
|
||||||
|
|
||||||
|
$ ls -1 | grep "js$" | wc -l
|
||||||
|
|
||||||
|
|
||||||
|
你能发现,这个管道有竖线“ | ”隔开的三个部分。第一个部分 ls -1,列出并返回了当前目录下所有的文件,这个结果作为了第二步 grep "js$" 的输入;第二个部分会过滤出所有的以 js 结尾的文件;然后第二步的结果会作为第三部分的输入,在第三步,我们会看到最后计算的结果。
|
||||||
|
|
||||||
|
JavaScript中的管道
|
||||||
|
|
||||||
|
回到JavaScript中,我们也可以用isOdd的例子,来看看同样的功能要如何通过管道来实现。
|
||||||
|
|
||||||
|
其实也很简单,我们只需要通过一个reverseArgs函数,将compose中接收参数的顺序反过来即可。
|
||||||
|
|
||||||
|
你可能会想到我们在上节课讲unary的时候,是把函数的输入参数减少到1,而这里是把参数做倒序处理,生成一个新的函数。在函数式编程中,这算是一个比较经典的高阶函数的例子。
|
||||||
|
|
||||||
|
function reverseArgs(fn) {
|
||||||
|
return function argsReversed(...args){
|
||||||
|
return fn( ...args.reverse() );
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var pipe = reverseArgs( compose );
|
||||||
|
|
||||||
|
|
||||||
|
然后我们可以测试下管道是否“畅通”。这次,我们把remainderOfTwo和equalsToOne按照比较直观的方式进行排序。
|
||||||
|
|
||||||
|
可以看到,isOdd(1)返回的结果是true,isOdd(2)返回的结果是false,和我们预期的结果是一样的。
|
||||||
|
|
||||||
|
const isOdd = pipe(remainderOfTwo, equalsToOne);
|
||||||
|
|
||||||
|
isOdd(1); // 返回 true
|
||||||
|
isOdd(2); // 返回 false
|
||||||
|
|
||||||
|
|
||||||
|
Transduction
|
||||||
|
|
||||||
|
讲完了组合和管道之后,还有一个地方想再跟你强调下。
|
||||||
|
|
||||||
|
我一再说过,函数式编程中的很多概念,都来自于对复杂、动力系统研究与控制等领域。而通过组合和管道,我们可以再延伸来看一下转导(transducing)。
|
||||||
|
|
||||||
|
转导主要用于控制系统(Control System),比如声波作为输入,通过麦克风进入到一个功放,然后功放进行能量转换,最后通过喇叭传出声音的这样一个系统,就可以成为转导。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
当然,单独看这个词,你或许并没有什么印象,但是如果说React.js,你应该知道这是一个很著名的前端框架。在这里面的reducer的概念,就用到了transducing。
|
||||||
|
|
||||||
|
在后面的课程中,我们讲到响应式编程和观察者模式的时候,还会更深入了解reducer。这里,我们就先来看看transduce和reducer的作用以及原理。
|
||||||
|
|
||||||
|
那么,reducer是做什么用的呢?它最主要的作用其实是解决在使用多个map、filter、reduce操作大型数组时,可能会发生的性能问题。
|
||||||
|
|
||||||
|
而通过使用transducer和reducer,我们就可以优化一系列map、filter、reduce操作,使得输入数组只被处理一次并直接产生输出结果,而不需要创建任何中间数组。
|
||||||
|
|
||||||
|
可能我这么讲,你还是不太好理解,这里我们先来举一个不用tansducer或reducer例子吧。
|
||||||
|
|
||||||
|
var oldArray = [36, 29, 18, 7, 46, 53];
|
||||||
|
var newArray = oldArray
|
||||||
|
.filter(isEven)
|
||||||
|
.map(double)
|
||||||
|
.filter(passSixty)
|
||||||
|
.map(addFive);
|
||||||
|
|
||||||
|
console.log (newArray); // 返回:[77,97]
|
||||||
|
|
||||||
|
|
||||||
|
在这个例子里,我们对一组数组进行了一系列的操作,先是筛选出奇数,再乘以二,之后筛出大于六十的值,最后加上五。在这个过程中,会不断生成中间数组。
|
||||||
|
|
||||||
|
这个实际发生的过程如下图左半部分所示。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
而如果使用reducer的话,我们对每个值只需要操作一次,就可产出最终的结果。如上图的右半部分所示。
|
||||||
|
|
||||||
|
那么它是如何实现的呢?在这里,我们是先将一个函数,比如isEven作为输入,放到了一个transducer里,然后作为输出,我们得到的是一个isEvenR的reducer函数。
|
||||||
|
|
||||||
|
是的,这里的transducer其实也是一个经典的高阶函数(即输入一个函数,得到一个新的函数)的例子!
|
||||||
|
|
||||||
|
实际上,像 double和addFive都具有映射类的功能,所以我们可以通过一个类似mapReducer这样的一个transducer,来把它们转换成reducer。而像 isEven和passSixty都是筛选类的功能,所以我们可以通过一个类似filterReducer这样的一个transducer,来把它们转换成 reducer。
|
||||||
|
|
||||||
|
如果我们抽象化来看,其代码大致如下。它的具体实现这里我卖个关子,你可以先自己思考下,我们下节课再探讨。
|
||||||
|
|
||||||
|
var oldArray = [36, 29, 18, 7, 46, 53];
|
||||||
|
|
||||||
|
var newArray = composeReducer(oldArray, [
|
||||||
|
filterTR(isEven),
|
||||||
|
mapTR(double),
|
||||||
|
filterTR(passSixty),
|
||||||
|
mapTR(addFive),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log (newArray); // 返回:[77,97]
|
||||||
|
|
||||||
|
|
||||||
|
总而言之,从上面的例子中,我们可以看出来composeReducer用的就是一个类似组合的功能。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这节课通过对组合和管道的了解,相信你可以看出来,它们和上节课我们讲到的部分应用和柯里化正好相反,一个是从具象走向抽象,一个是从抽象走向具象。
|
||||||
|
|
||||||
|
不过,虽然说它们的方向是相反的,但有一条原则是一致的,那就是每个函数尽量有一个单一职责,只专注做好一件事。
|
||||||
|
|
||||||
|
值得注意的是,这里的方向不同,并不是指我们要用抽象取代具象,或者是用具象取代抽象。而是说它们都是为了单一职责函数的原则,相辅相成地去具象化或抽象化。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
另外,通过reducer的例子,我们也知道了如何通过reducer的组合,做到普通的组合达不到的性能提升。
|
||||||
|
|
||||||
|
在这节课里,我们是先从一个抽象层面理解了reducer,不过你可能仍然对map、filter、reduce等概念和具体实现感到有些陌生。不用担心,下节课我就带你来进一步了解这一系列针对值的操作工具的机制,以及functor和monad。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
我们讲到reduce可以用来实现map和filter ,那么你知道这背后的原理吗?欢迎在留言区分享你的答案,或者你如果对此并不十分了解,也希望你能找找资料,作为下节课的预习内容。
|
||||||
|
|
||||||
|
当然,你也可以在评论区交流下自己的疑问,我们一起讨论、共同进步。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
255
专栏/JavaScript进阶实战课/05map、reduce和monad如何围绕值进行操作?.md
Normal file
255
专栏/JavaScript进阶实战课/05map、reduce和monad如何围绕值进行操作?.md
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
05 map、reduce和monad如何围绕值进行操作?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
上节课里,我们在学习组合和管道的工作机制的时候,第一次认识了reducer,同时在讲到transduce的时候,也接触到了map、filter和reduce这些概念。那么今天这节课,我们就通过JS中数组自带的功能方法,来进一步了解下transduce的原理,做到既知其然,又知其所以然。
|
||||||
|
|
||||||
|
另外,我们也看看由map作为functor可以引申出的monad的概念,看看它是如何让函数间更好地进行交互的。
|
||||||
|
|
||||||
|
数据的核心操作
|
||||||
|
|
||||||
|
那在正式开始之前,我先来说明下这节课你要关注的重点。课程中,我会先带你通过JavaScript本身自带的映射(map)、过滤(filter)和reduce方法,来了解下这几种方法对值的核心操作。同时呢,我也给你解答下上节课提出的问题,即如何通过映射和过滤来做到reduce。作为我们后面讲到functor和monad的基础。
|
||||||
|
|
||||||
|
好,下面我们就从map开始讲起。
|
||||||
|
|
||||||
|
map映射和函子
|
||||||
|
|
||||||
|
我们经常听说,array.map就是一个函子(functor),那什么是一个函子呢?
|
||||||
|
|
||||||
|
实际上,函子是一个带运算工具的数据类型或数据结构值。举个常用的例子,在JavaScript中,字符串(string)就是一个数据类型,而数组(array)既是一个数据类型也是一个数据结构,我们可以用字符串来表达一个单词或句子。而如果我们想让下图中的每个字母都变成大写,那么就是一个转换和映射的过程。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
我们再用一段抽象的代码来表示一个字符串的映射函子stringMap。可以看到,stringMap可以把字符串Hello World!作为输入,然后通过一个uppercaseLetter工具函数的转换,对应返回大写的HELLO WORLD!。
|
||||||
|
|
||||||
|
stringMap( uppercaseLetter, "Hello World!" ); // HELLO WORLD!
|
||||||
|
|
||||||
|
|
||||||
|
类似地,如果我们有一个数组的映射函子arrayMap,也可以把数组 [“1”,“2”,“3”] 中每个字符串的元素转化成整数,然后再对应输出一个整数数组 [1, 2, 3]。
|
||||||
|
|
||||||
|
["1","2","3","4","5"].map( unary( parseInt ) ); // [1,2,3,4,5]
|
||||||
|
|
||||||
|
|
||||||
|
filter过滤和筛选
|
||||||
|
|
||||||
|
说完了函子,我们再来看看过滤器(filter)和断言(predicate)。
|
||||||
|
|
||||||
|
filter顾名思义,就是过滤的意思。但要注意一点,filter可以是双向的,我们可以过滤掉(filter out)不想要的东西,也可以筛选出(filter in)出想要的东西。
|
||||||
|
|
||||||
|
然后再来看断言。我们上节课在说到处理输入参数的工具的时候,也接触过断言,比如identity就可以看作是断言。在函数式编程中,断言就是一个个的筛选条件,所以在过滤器中,我们经常会使用断言函数。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
举个例子,假如有一个用来判断“一个值是不是奇数”的isOdd函数,它是一个断言,而它的筛选条件就是筛选出数组中的单数。所以,如果用它来筛选 [1,2,3,4,5],得到的结果就是 [1,3,5]。
|
||||||
|
|
||||||
|
[1,2,3,4,5].filter( isOdd ); // [1,3,5]
|
||||||
|
|
||||||
|
|
||||||
|
在Javascript中也有自带的 some() 和 every() 断言方法。它们的作用就是可以判断数组中的一组元素是不是都符合判断条件。
|
||||||
|
|
||||||
|
比如在下面一列包含了 [1,2,3,4,5] 这几个数字的数组中,如果我们要判断它的每一个元素是不是都小于6,结果就是true。如果我们要判断它们是不是都是奇数,结果就是false,因为这里面既有奇数,也有偶数。
|
||||||
|
|
||||||
|
let arr = [1,2,3,4,5];
|
||||||
|
arr.every(x => x < 6) // => true,所有的值都小于6
|
||||||
|
arr.every(x => x % 2 === 1) // => false,不是所有的数都是奇数
|
||||||
|
|
||||||
|
|
||||||
|
类似地,some()可以帮助我们判断这组数组中有没有一些小于6的数字或者奇数。这时,这两个判断返回的结果都是true。
|
||||||
|
|
||||||
|
let arr = [1,2,3,4,5];
|
||||||
|
arr.some(x => x < 6) // => true,a里面有小于6的数字
|
||||||
|
arr.some(x => x % 2 === 1) // => true,数组a里面有一些奇数
|
||||||
|
|
||||||
|
|
||||||
|
虽然some() 和 every() 都是 JavaScript自带的断言方法,但是对比 filter() ,它们就显得没有那么“函数式”了,因为它们的返回值只是一个 true 或 false,而没有像 filter 一样返回一组数据作为输出,继续用来进行后续一系列的函数式的操作。
|
||||||
|
|
||||||
|
reduce和缩减器
|
||||||
|
|
||||||
|
最后我们再来说说reduce。实际上,缩减(reduce)主要的作用就是把列表中的值合成一个值。如下图所示:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在reduce当中,有一个缩减器(reducer)函数和一个初始值。比如在下面的例子中,初始值是3,reducer函数会计算3乘以5的结果,再乘以10,得出的结果再乘以15,最后归为一个结果2250。
|
||||||
|
|
||||||
|
[5,10,15].reduce( (arr,val) => arr * val, 3 ); // 2250
|
||||||
|
|
||||||
|
|
||||||
|
而缩减reduce除了能独立来实现以外,也可以用映射map和过滤filter的方法来实现。这是因为 reduce的初始值可以是一个空数组[],这样我们就可以把迭代的结果当成另一个数组了。
|
||||||
|
|
||||||
|
我们来看一个例子:
|
||||||
|
|
||||||
|
var half = v => v / 2;
|
||||||
|
[2,4,6,8,10].map( half ); // [1,2,3,4,5]
|
||||||
|
|
||||||
|
[2,4,6,8,10].reduce(
|
||||||
|
(list,v) => (
|
||||||
|
list.push( half( v ) ),
|
||||||
|
list
|
||||||
|
), []
|
||||||
|
); // [1,2,3,4,5]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
var isEven = v => v % 2 == 0;
|
||||||
|
[1,2,3,4,5].filter( isEven ); // [2,4]
|
||||||
|
|
||||||
|
[1,2,3,4,5].reduce(
|
||||||
|
(list,v) => (
|
||||||
|
isEven( v ) ? list.push( v ) : undefined,
|
||||||
|
list
|
||||||
|
), []
|
||||||
|
); // [2,4]
|
||||||
|
|
||||||
|
|
||||||
|
可以发现,这里我故意利用了一个副作用。通过第一节课的学习,我们知道array.push是一个非纯函数的方法,它改变了原数组,而不是复制后修改。而如果我们想完全避免副作用,可以用concat。但是,我们也知道concat虽然遵循的是纯函数、不可变的原则,但是有一点是我们需要注意的,就是它在面对大量的复制和修改时会产生性能上的问题。所以估计到这里,你也猜到了在上节课中,我们提到的transducer的原理了。
|
||||||
|
|
||||||
|
是的,这里我们就是故意利用了副作用来提高性能!
|
||||||
|
|
||||||
|
你或许会认为,这样是不是就违背了纯函数和不可变的原则?实际上是也不是,因为在原则上,我们做的这些变化都是在函数内部的,而我在前面说过,需要注意的副作用一般多是来自外部。
|
||||||
|
|
||||||
|
所以在这个例子中,我们没有必要为了几乎没有负面影响的副作用而牺牲性能。而transducer正是利用了副作用,才做到的性能提升。
|
||||||
|
|
||||||
|
单子monad
|
||||||
|
|
||||||
|
好,现在让我们回到课程一开始提到的问题:monad和functor有什么区别呢?
|
||||||
|
|
||||||
|
在开篇词我们也提到过,函子(functor)其实就是一个值和围绕值的一些功能。所以我们知道,array.map可以被看做是一个functor,它有一组值,而如map这样的方法可以作用于数组里面的每一个值,提供了一个映射的功能。而monad就是在functor的基础上,又增加了一些特殊功能,其中最常见的就是 chain和应用函子(applicative)。下面我就带你具体看看。
|
||||||
|
|
||||||
|
array作为functor
|
||||||
|
|
||||||
|
前面我们说过,array.map就是一个函子,它有一个自带的包装对象,这个对象有类似map这样的映射功能。那么同样地,我们也可以自己写一个带有映射方法的Just Monad,用它来包装一个值(val)。这个时候,monad相当于是一个基于值形成的新的数据结构,这个数据结构里有map的方法函数。
|
||||||
|
|
||||||
|
function Just(val) {
|
||||||
|
return { map };
|
||||||
|
|
||||||
|
function map(fn) { return Just( fn( val ) ); }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
可见,它的使用方式就类似于我们之前看到的array.map映射。比如在下面的例子里,我们用map将一个函数 v => v * 2 运用到了Just monad封装的值10上,它返回的就是20。
|
||||||
|
|
||||||
|
var A = Just( 10 );
|
||||||
|
var B = A.map( v => v * 2 ); // 20
|
||||||
|
|
||||||
|
|
||||||
|
chain作为bind、flatMap
|
||||||
|
|
||||||
|
再来说说chain。
|
||||||
|
|
||||||
|
chain通常又叫做flatMap或bind,它的作用是flatten或unwrap,也就是说它可以展开被Just封装的值val。你可以使用chain将一个函数作用到一个包装的值上,返回一个结果值。如下代码所示:
|
||||||
|
|
||||||
|
function Just(val) {
|
||||||
|
return { map, chain };
|
||||||
|
|
||||||
|
function map(fn) { return Just( fn( val ) ); }
|
||||||
|
|
||||||
|
// aka: bind, flatMap
|
||||||
|
function chain(fn) { return fn( val ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
我再举个例子,我们用chain方法函数把一个加一的函数作为参数运用到monad A上,得到了一个 15+1=16 的结果,那么之后返回的就是一个flatten或unwrap展开的16了。
|
||||||
|
|
||||||
|
var A = Just( 15 );
|
||||||
|
var B = A.chain( v => v + 1 );
|
||||||
|
|
||||||
|
B; // 16
|
||||||
|
typeof B; // "number"
|
||||||
|
|
||||||
|
|
||||||
|
monoid
|
||||||
|
|
||||||
|
OK,既然说到了chain,我们也可以看一下monoid。
|
||||||
|
|
||||||
|
在上节课我们说过函数组合compostion。而在组合里,有一个概念就是签名一致性的问题。举个例子如果前一个函数返回了一个字符串,后一个函数接收的输入是数字,那么它们是没办法组合的。所以,compose函数接收的函数都要符合一致性的 fn :: v -> v 函数签名,也就是说函数接收的参数和返回的值类型要一样。
|
||||||
|
|
||||||
|
那么,满足这些类型签名的函数就组成了 monoid。看到这个公式你是不是觉得很眼熟?没错,它的概念就是基于我们之前说到过的 identity函数。在TypeScript中,identity也是泛型使用中的一个例子。比如在C#和Java这样的语言中,泛型可以用来创建可重用的组件,一个组件可以支持多种类型的数据。 这样用户就可以以自己的数据类型来使用组件。它的基本原理也是基于这样的一个identity函数。
|
||||||
|
|
||||||
|
function identity<T>(arg: T): T {
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
identity在monad中有一个用处,就是如果把identity作为一个参数,可以起到观察inspect的作用。比如,我们先用Just来封装 15 这个值,然后调用chain的方法时,把identity作为参数,返回的就是一个flatten或unwrap展开的15。所以我们可以看出,它也这里也起到了一个log的作用。
|
||||||
|
|
||||||
|
var A = Just( 15 );
|
||||||
|
A.chain (identity) // 返回 15
|
||||||
|
|
||||||
|
|
||||||
|
applicative
|
||||||
|
|
||||||
|
最后,我们再来看应用函子(applicative),简称ap。
|
||||||
|
|
||||||
|
ap的作用其实也很简单。应用函子,顾名思义,它的作用是可以把一个封装过的函数应用到一个包装过的值上。
|
||||||
|
|
||||||
|
function Just(val) {
|
||||||
|
return { map, ap };
|
||||||
|
|
||||||
|
function map(fn) { return Just( fn( val ) ); }
|
||||||
|
|
||||||
|
function ap(anotherMonad) { return anotherMonad.map( val ); }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
再来看一个例子,可以看到,ap把monad B里的值取出来,通过monad A的映射把它应用到了monad A上。因为映射接受的值类型是函数,所以这里我们传入的是柯里化的add函数,它先通过闭包的记忆功能,记住第一个参数6,之后再加上传入的10,最后输出的结果就是16。
|
||||||
|
|
||||||
|
var A = Just( 6 );
|
||||||
|
var B = Just( 10 );
|
||||||
|
|
||||||
|
function add(x,y) { return x + y; }
|
||||||
|
|
||||||
|
var C = A.map( curry( add ) ).ap( B );
|
||||||
|
|
||||||
|
C.chain(identity); // 返回 16
|
||||||
|
|
||||||
|
|
||||||
|
如果我们把上面几个功能加在一起,其大致实现就如下所示:
|
||||||
|
|
||||||
|
function Just(val) {
|
||||||
|
return { map, chain, ap, log };
|
||||||
|
|
||||||
|
// *********************
|
||||||
|
|
||||||
|
function map(fn) { return Just( fn( val ) ); }
|
||||||
|
|
||||||
|
// aka: bind, flatMap
|
||||||
|
function chain(fn) { return fn( val ); }
|
||||||
|
|
||||||
|
function ap(anotherMonad) { return anotherMonad.map( val ); }
|
||||||
|
|
||||||
|
function log() {
|
||||||
|
return `simpleMonad(${ val })`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
说到函子和应用函子,我们也可以看一下,在数组中,有一个array.of的工厂方法,它的作用是接收一组参数,形成一个新数组。
|
||||||
|
|
||||||
|
var arr = Array.of(1,2,3,4,5); // 返回:[1,2,3,4,5]
|
||||||
|
|
||||||
|
|
||||||
|
在函数式编程中,我们称实现了of工厂方法的函子是pointed函子。通过pointed函子,我们可以把一组值放到了一个数组的容器中,之后还可以通过映射函子对每个值做映射。而应用函子,(applicative functor)就是实现了应用方法的pointed函子。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
今天这节课,我们学习了函数式编程中针对数组的几个核心操作,解答了上节课中的如何通过映射和过滤做到reduce的问题,同时也更深入地理解了reducer和transduce的原理。
|
||||||
|
|
||||||
|
并且现在我们知道,array.map其实就是一个functor,它包含了map功能,可以围绕一个数组中的每个值进行操作,返回一个新的数组。而monad可以说是基于函子增加了一些特殊的功能。当然了,不同的monad也可以相互组合,比如just加上nothing,也就是一个空值单子,可以组成maybe monad来处理空值的异常。
|
||||||
|
|
||||||
|
另外说到了函子和单子,在函数式编程当中其实还有either、IO之类的概念。其中either是用来代替比如if else或者是try catch的条件运算,它的value里保存的是一个值;而IO可以用来延迟函数的执行,它的value里面存储的是一个函数。这里我就不多说了,感兴趣的话,你也可以去深入了解下。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
从函数式编程的思维视角来看,你觉得JavaScript中的promise算是monad吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的思考和答案,也欢迎你把今天的内容分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
173
专栏/JavaScript进阶实战课/06如何通过模块化、异步和观察做到动态加载?.md
Normal file
173
专栏/JavaScript进阶实战课/06如何通过模块化、异步和观察做到动态加载?.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
06 如何通过模块化、异步和观察做到动态加载?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在前面几节讲函数式编程的课程里,我们了解了在函数式编程中副作用通常是来自于函数外部,多在输入的过程中会出现副作用。这实际上是从空间的角度来看的。
|
||||||
|
|
||||||
|
而今天这节课,我们会从时间的角度来看看异步中的事件如何能引起副作用,以及要如何管理这种副作用。
|
||||||
|
|
||||||
|
如何处理异步事件中的时间状态?
|
||||||
|
|
||||||
|
实际上,在函数式编程中我们在讨论异步的时候,经常会说到信任(trustable)和承诺(promise)。这个其实是源自于合同或者是契约法中的一个概念,而且它不只限于经典的合同,我们说的智能合约之类的概念中,底层逻辑也都源于契约和共识。
|
||||||
|
|
||||||
|
那么,为什么我们在处理异步时需要用到这个概念呢?下面我就先带你来看看在异步时,程序都会遇到哪些问题。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
假设,我们有以下getUser和getOrders两个函数,分别通过用户ID来获取用户信息和订单信息。如果getUser先得到响应的话,那么它就没法获得订单信息。同样地,如果getOrders先得到响应的话,那么它就没办法获得用户信息。
|
||||||
|
|
||||||
|
这样一来,我们说这两个函数就形成了一个竞争条件(Race Condition)。
|
||||||
|
|
||||||
|
var user;
|
||||||
|
|
||||||
|
getUser( userId, function onUser(userProfile){
|
||||||
|
var orders = user ? user.orders : null;
|
||||||
|
user = userProfile;
|
||||||
|
if (orders) {
|
||||||
|
user.orders = orders;
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
getOrders( userId, function onOrders(userOrders){
|
||||||
|
if (!user) {
|
||||||
|
user = {};
|
||||||
|
}
|
||||||
|
user.orders = userOrders;
|
||||||
|
} );
|
||||||
|
|
||||||
|
|
||||||
|
从下图也可以看出,无论是谁先到达都会出现问题。那么你可能会说,把它们一前一后分开处理不就行了吗?但是这样的话就没有办法做到并行,只能串行,而串行所花的总时间一般会高于并行。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在这里,时间就是状态。在同步操作中我们不需要考虑时间;而在异步中时间就产生了。时间不仅仅是状态,也是最难管理的状态。
|
||||||
|
|
||||||
|
这和信任和承诺又有什么关系呢?因为信任和承诺之间隔着的就是时间!还是以合同交易举例,如果是同步的话,相当于一手交钱、一手交货。而在异步中,则只能靠时间证明是否遵守了承诺。所以在JavaScript里,解决异步问题的工具就叫承诺(promise)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
可是,凭什么我们要相信一个承诺呢?在生活中的交易,大家通常会走个合同,剩下的交给时间来证明。而在JavaScript函数式编程当中,解决方案就是不用合同这么麻烦了,为了让你相信我的承诺,咱们干脆直接把时间给干掉。
|
||||||
|
|
||||||
|
是的,就是这么霸气。把时间干掉的方式就是按照同步的方式来处理异步,下面是一个例子:
|
||||||
|
|
||||||
|
var userPromise = getUser( userId );
|
||||||
|
var ordersPromise = getOrders( userId );
|
||||||
|
|
||||||
|
userPromise.then( function onUser(user){
|
||||||
|
ordersPromise.then( function onOrders(orders){
|
||||||
|
user.orders = orders;
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
|
||||||
|
这样,即使是并行获取的用户和订单信息,在处理的时候我们也可以通过then按照同步处理时的先后顺序,来更新订单信息到用户对象上。
|
||||||
|
|
||||||
|
如何处理循环事件中的时间状态?
|
||||||
|
|
||||||
|
在函数式+响应式编程中,除了网络事件,还有更多的例子是通过去掉时间,比如循环或用户事件,都可以用类似同步的方式来处理异步事件。
|
||||||
|
|
||||||
|
举个例子,比如我们有生产者和消费者两个对象,消费者希望在生产者发生改变的时候,能随之映射出改变。这时候如果我们的生产者很“勤奋”,实时地在生产,消费者也可以实时地来消费。
|
||||||
|
|
||||||
|
// 勤奋生产者
|
||||||
|
var producer = [1,2,3,4,5];
|
||||||
|
|
||||||
|
// 消费者
|
||||||
|
var consumer = producer.map( function triple(v){
|
||||||
|
return v * 3;
|
||||||
|
} ); // [3,6,9,12,15];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
但是如果有一个懒惰的生产者,消费者不知道在未来哪个时间该生产者会发生变化,那么要怎么办呢?
|
||||||
|
|
||||||
|
这时,我们就需要把懒惰的生产者当做一个被观察对象,每当它发生变化时,就随之做出反应,这就是“函数式中的异步模式”和“响应式中的观察者模式”的结合。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
下面是一个相对抽象的异步循环事件的例子。不过在现实当中,我们遇到的用户输入,比如鼠标的点击、键盘的输入等等的DOM事件也是异步的。所以这个时候,我们就可以用到“懒”这个概念,根据用户反应来“懒加载”一些内容。
|
||||||
|
|
||||||
|
/* 例如使用RxJS,一个响应式JS的扩展 */
|
||||||
|
|
||||||
|
// 懒惰生产者
|
||||||
|
var producer = Rx.Observable.create( function onObserve(observer){
|
||||||
|
setInterval( function everySecond(){
|
||||||
|
observer.next( Math.random() );
|
||||||
|
}, 1000 );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// 消费者
|
||||||
|
var consumer = producer.map( function triple(v){
|
||||||
|
return v * 3;
|
||||||
|
} );
|
||||||
|
consumer.subscribe( function onValue(v){
|
||||||
|
console.log( v );
|
||||||
|
} );
|
||||||
|
|
||||||
|
|
||||||
|
如何处理用户事件中的时间状态?
|
||||||
|
|
||||||
|
接着我们再从响应式和观察者模式延伸,来看看前端在处理页面上内容的动态加载时使用的一些方法。这就要说到动态导入了。
|
||||||
|
|
||||||
|
我们先来看看网页上的一个模块从加载到执行的顺序。可以看到,这个顺序大致分成了4个步骤,第一是加载,之后是解析、编译,最后是执行。如果是动态加载,就是在初始化之后,根据需求再继续加载。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
而说到动态导入,基本可以分成两类,一类是可视时加载(load on visibility),一种是交互时加载(load on interaction)。
|
||||||
|
|
||||||
|
可视时加载就是我们经常说的懒加载(Lazy loading),这种方式经常用在长页面当中。比如产品详情页,一般都是用讲故事的方式一步步介绍产品卖点,用图说话,最后再展示参数、一键购买以及加购物车的部分。所以也就是说我们不需要一上来就加载整个页面,而是当用户滑动到了某个部分的时候,再加载相关的内容。
|
||||||
|
|
||||||
|
交互时加载就是当用户和页面进行交互时,比如点击了某个按钮后,可能产生的加载。举个例子,有些应用中的日历,只有用户在进行特定操作的时候才会显示并且和用户交互。这样的模块,我们就可以考虑动态加载。
|
||||||
|
|
||||||
|
注意,这里有几个重要的指标。在初始化的加载中,我们关注的通常是首次渲染时间(FCP,First Contentful Paint)和最大内容渲染时间(LCP,Largest Contentful Paint),也就是页面首次加载的时候。在后续的动态加载中,我们关注的是首次交互时间(TTI,Time to Interactive),也就是当用户开始从首屏开始往下滑动,或者点击了某个按钮开启了日历弹窗的时候。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
你可能会觉得,这样的优化只能省下一两百KB或几个MB,是否值得?但是苍蝇腿也是肉,而且积少成多,当你在开发一个复杂的Web应用需要不断扩充模块的时候,这样的操作可能就会产生质和量上的变化。
|
||||||
|
|
||||||
|
然后在这个时候,我们通常会通过一些打包工具,比如用Webpack先加载核心的组件,渲染主程序,之后根据交互的需要,按需加载某个模块。
|
||||||
|
|
||||||
|
另外,对于动态的加载,其实也有很多三方的库可以支持,其中一个例子就是React中的Suspense。如果是Node服务器端的加载渲染的话,也有Loadable Components这样的库可以用来参考。当然,如果你不使用这些三方的库,自己开发也可以,但是原理始终是类似的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
不过这里我还想说下,在使用动态导入前,一般应该先考虑预加载(pre-load)或预获取(pre-fetch)。
|
||||||
|
|
||||||
|
它们两个的区别是,前者是在页面开始加载时就提前开始加载后面需要用到的元素;后者是在页面的主要功能都加载完成后,再提前加载后面需要用到的素材。除非没法做到预加载和预获取,或者你加载的是三方的内容,不可控,不然的话,这些方式都可以带来比动态加载更好的用户体验。
|
||||||
|
|
||||||
|
那么,有没有没法儿做到、或者不适合做预加载的例子呢?也是有的,比如要预加载的内容过大,而且用户不一定会使用预加载的内容的时候。
|
||||||
|
|
||||||
|
这个时候如果你事先加载,一是会使用到用户的网络,占用了用户手机的存储空间;二是也会增加自己的CDN和服务器的资源消耗。这种情况下,就要用到动态加载了。
|
||||||
|
|
||||||
|
另外在进一步看动态加载前,我们还要了解两个基础概念,就是页面渲染的两种基础渲染模式。一种是浏览器渲染,一种是服务器端渲染。
|
||||||
|
|
||||||
|
首先,在客户端渲染(CSR,client side rendering)模式下,我们是先下载HTML、JS和CSS,包括数据,所有的内容都下载完成,然后再开始渲染。
|
||||||
|
|
||||||
|
而SSR服务器端渲染(SSR,server side rendering)模式下,我们是先让用户能看到一个完整的页面,但是无法交互。只有等相关数据从服务器端加载和hydrate后,比如说一个按钮加上了的相关事件处理之后,才能交互。
|
||||||
|
|
||||||
|
这个方案看上去比CSR会好一些,但它也不是没有问题的。比如说,我们作为用户使用一些应用有时候,也会遇到类似的问题,就是我们在加载和hydrate前点击某个按钮的时候,就会发现某个组件没反应。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那么在交互驱动的动态加载中,上面这种问题怎么解决呢?比如Google,他们会使用并且开源了一个叫JSAction 的小工具,它的作用就是先加载一部分轻代码(tiny code),这部分代码可以“记住”用户的行为,然后根据用户的交互来加载组件,等加载完成再让组件执行之前“记住”的用户请求。这样就完美解决了上述问题。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过今天的学习,我们理解了函数式编程+响应式编程中,时间是一个状态,而且是一个最难管理的状态。而通过promise的概念我们可以消除时间,并且可以通过同步的方式来处理异步事件。
|
||||||
|
|
||||||
|
另外,通过观察者模式我们也可以更好地应对未知,通过行动来感知和响应,这样的加载方式,在应用的使用者达到一定规模化的时候,可以减少不必要和大量的资源浪费。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
我们说根据事件的动态加载可以起到降本增效的作用,那么你能说说你在前端开发中做资源加载设计、分析和优化的经验吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
264
专栏/JavaScript进阶实战课/07深入理解对象的私有和静态属性.md
Normal file
264
专栏/JavaScript进阶实战课/07深入理解对象的私有和静态属性.md
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
07 深入理解对象的私有和静态属性
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在前面几讲里,我们围绕着函数式编程,从基础的输入、计算、输出讲起,到过程中可能产生的副作用,再到如何通过纯函数和不可变作为解决思路来管理副作用等等,都有了系统的了解。之后,我们又通过响应式编程和函数式编程的结合,了解了这种模式下面,如何面对未知以及基于事件变化做出响应。
|
||||||
|
|
||||||
|
从这节课开始,我们再来深入了解下 JavaScript的对象构建和面向对象的编程模式。
|
||||||
|
|
||||||
|
在第1讲里,我们第一次介绍到了对象和面向对象。对象其实就好比一个人,生来都是带有属性和功能的,比如肤色、身高等等就是我们的属性,会哭会笑这些就是我们的功能。我们作为对象和别的对象之间要产生交互,这就是面向对象的设计。那今天我们就从一个对象的创建讲起。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在面向对象的设计中,一个对象的属性是至关重要的,因为它也决定了对象是什么、能做什么。一个对象可以有对外分享的、别人可以获取的公开属性,也有不对外暴露的、别人不可以随便获取的私有属性。
|
||||||
|
|
||||||
|
除了公开和私有属性,还有静态属性。静态属性是属于类,而不是单独属于对象的。这么解释听上去可能有些绕口,我们可以打个比方,比如说中国有14亿人口,那么“14亿人口”就是属于中国这个国家类的属性,但是我们不能说具体每一个中国人具有“14亿人口”这个属性。
|
||||||
|
|
||||||
|
同样地,静态属性也包含公开属性和私有属性。就好比一个公司作为一个组织类,通常为了体现规模,会公开自己有多少员工,这就是公司的公开静态属性;但是公司不会公开一些运营数据,因为这个是敏感话题,只有审计的时候或特定场合才提供,这个运营数据可能就属于私有静态属性。
|
||||||
|
|
||||||
|
目前用于创建私有属性和静态属性的支持,都是在2022年6月后,也就是在JavaScript出现后的25年,才纳入ECMAScript规范中的(但其实除了已经退役的IE浏览器外,几乎大多数主流浏览器之前都支持了这两个功能)。但是在此之前,人们就通过其它方式,试着实现类似的功能了。
|
||||||
|
|
||||||
|
今天这节课,我们就来看看它们实现的底层逻辑和应用。
|
||||||
|
|
||||||
|
如何创建私有属性?
|
||||||
|
|
||||||
|
在面向对象中,有个很重要的概念就是创建私有属性。
|
||||||
|
|
||||||
|
我们可以看到,和Java不一样,当用JavaScript创建一个Widget对象时,无论是使用class、对象字面量还是函数构造式,一般的情况下,在定义了属性和方法后就可以公开调用,并没有任何限制。
|
||||||
|
|
||||||
|
// 示例1:类class
|
||||||
|
class WidgetA {
|
||||||
|
constructor() {
|
||||||
|
this.appName = "天气应用"
|
||||||
|
}
|
||||||
|
getName(){
|
||||||
|
return this.appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var widget1 = new WidgetA();
|
||||||
|
console.log(widget1.appName); // 返回 “天气应用”
|
||||||
|
console.log(widget1.getName()); // 返回 “天气应用”
|
||||||
|
|
||||||
|
// 示例2:对象字面量
|
||||||
|
var WidgetB = {
|
||||||
|
appName : "天气应用",
|
||||||
|
getName : function (){
|
||||||
|
return this.appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(WidgetB.appName); // 返回 “天气应用”
|
||||||
|
console.log(WidgetB.getName()); // 返回 “天气应用”
|
||||||
|
|
||||||
|
// 示例3:函数构造式
|
||||||
|
function WidgetC(){
|
||||||
|
this.appName = "天气应用";
|
||||||
|
this.getName = function (){
|
||||||
|
return "天气应用";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var widget3 = new WidgetC();
|
||||||
|
console.log(widget3.appName); // 返回 “天气应用”
|
||||||
|
console.log(widget3.getName()); // 返回 “天气应用”
|
||||||
|
|
||||||
|
|
||||||
|
用#符号创建私有属性
|
||||||
|
|
||||||
|
那怎么才能在对象中创建私有属性呢?根据最新的ES13 规范,我们可以通过#符号,来定义一个私有的属性。
|
||||||
|
|
||||||
|
首先,我们声明了一个#appName,在构建者constructor里,我们给它赋值为“天气应用”。这时,当我们直接调取appName时,会看到返回的结果就是未定义的。但如果我们通过getName方法,就可以获取appName的值。
|
||||||
|
|
||||||
|
class WidgetD {
|
||||||
|
#appName;
|
||||||
|
constructor(){
|
||||||
|
this.#appName = "天气应用";
|
||||||
|
}
|
||||||
|
getName(){
|
||||||
|
return this.#appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var widget4 = new WidgetD();
|
||||||
|
console.log(widget4.appName); // 返回 undefined
|
||||||
|
console.log(widget4.getName()); // 返回 “天气应用”
|
||||||
|
|
||||||
|
|
||||||
|
所以下面,我们就一起来看看在#问世之前,工程师们是怎么实现私有属性的。主要有闭包、WeakMap和Symbol这三种方式。
|
||||||
|
|
||||||
|
用闭包和IIFE创建私有属性
|
||||||
|
|
||||||
|
我们先来看看如何在对象字面量中创建私有属性。是的,我们在前面讲函数式编程时,提到过的闭包在这里派上用场了。
|
||||||
|
|
||||||
|
首先,我们声明一个WidgetE的变量,然后再来创建一个立即被调用的函数式表达(IIFE),在这个表达里面,我们先给内部的appName变量赋值为“天气应用”。
|
||||||
|
|
||||||
|
之后,在函数中我们再给WidgetE赋值,这里赋值的是一个对象,里面我们定义了getName的方法,它返回的就是外部函数的appName。
|
||||||
|
|
||||||
|
这个时候,当我们试图获取WedgetE.appName时,会发现无法获取嵌套函数内部声明的变量。但是当我们通过getName的方法,利用嵌套函数中内嵌函数可以访问外部函数的变量的特点,就可以获取相应的返回值。
|
||||||
|
|
||||||
|
// 对象字面量
|
||||||
|
var WidgetE;
|
||||||
|
(function(){
|
||||||
|
var appName = "天气应用";
|
||||||
|
WidgetE = {
|
||||||
|
getName: function(){
|
||||||
|
return appName;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}());
|
||||||
|
WidgetE.appName; // 返回 undefined
|
||||||
|
WidgetE.getName(); // 返回 “天气应用”
|
||||||
|
|
||||||
|
|
||||||
|
好,下面我们再来看看如何通过构造函数的方式,构造私有属性。
|
||||||
|
|
||||||
|
这里也可以通过我们学过的闭包,直接上代码。这个例子其实看上去要比上面的例子简单,我们先定义一个函数,在里面声明一个变量appName,然后创建一个getName的表达式函数,返回appName。
|
||||||
|
|
||||||
|
// 构造函数
|
||||||
|
function WidgetF() {
|
||||||
|
var appName = "天气应用";
|
||||||
|
this.getName = function(){
|
||||||
|
return appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var widget6 = new WidgetF();
|
||||||
|
console.log(widget6.appName); // 返回 undefined
|
||||||
|
console.log(widget6.getName()); // 返回 “天气应用”
|
||||||
|
|
||||||
|
|
||||||
|
这时候,我们通过函数构造可以创建一个新的函数widget6,但是通过这个新构建的对象来获取appName是没有结果的,因为在这里,appName是封装在WidgetF内部。不过widget6可以通过getName来获取appName,同样,这里是利用闭包的特点,来获取函数之外的变量。
|
||||||
|
|
||||||
|
可是这个例子中还有一个问题,就是我们每次在创建一个新对象的时候,私有属性都会被重新创建一次,这样就会造成重复工作和冗余内存。解决这个问题的办法就是把通用的属性和功能赋值给prototype,这样通过同一个构建者创建的对象,可以共享这些隐藏的属性。
|
||||||
|
|
||||||
|
比如我们来看下面的例子,我们给WidgetG的原型赋值了一个函数返回的对象,函数中包含了私有属性,返回的对象中包含了获取属性的方法。这样我们在创建一个widget7的对象之后,就能看到它可以获取天气应用支持的机型了。
|
||||||
|
|
||||||
|
function WidgetG() {
|
||||||
|
var appName = "天气应用";
|
||||||
|
this.getName = function(){
|
||||||
|
return appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WidgetG.prototype = (function(){
|
||||||
|
var model = "支持安卓";
|
||||||
|
return {
|
||||||
|
getModel: function(){
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
var widget7 = new WidgetG();
|
||||||
|
console.log(widget7.getName()); // 返回 “天气应用”
|
||||||
|
console.log(widget7.getModel()); // 返回 “支持安卓”
|
||||||
|
|
||||||
|
|
||||||
|
用WeakMap创建私有属性
|
||||||
|
|
||||||
|
在ES6 中,JavaScript引入了Set和Map的数据结构。Set和Map主要用于数据重组和数据储存。Set用的是集合的数据结构,Map用的是字典的数据结构。Map具有极快的查找速度,后面课程中我们在讲数据结构和算法的时候,还会详细介绍。在这里,我们主要先看WeakMap,它的特点是只接受对象作为键名,键名是弱引用,键值可以是任意的。
|
||||||
|
|
||||||
|
在下面的例子中,我们首先声明了一个WidgetG变量。接下来,建立一个块级作用域,在这个作用域里,我们再声明一个privateProps的WeakMap变量。然后我们给WidgetG赋值一个函数声明,在里面给WeakMap的键名设置为this,键值里面的appName为“天气应用”。下一步,我们基于WidgetF的prototype来创建一个getName方法,里面返回了appName的值。
|
||||||
|
|
||||||
|
利用这样的方式,就可以同时达到对appName的封装和通过getName在外部对私有属性值的获取了。
|
||||||
|
|
||||||
|
var WidgetH;
|
||||||
|
{
|
||||||
|
let privateProps = new WeakMap();
|
||||||
|
|
||||||
|
WidgetH = function(){
|
||||||
|
privateProps.set(this,{appName : "天气应用"});
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetH.prototype.getName = function(){
|
||||||
|
return privateProps.get(this).appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var widget8 = new WidgetH();
|
||||||
|
console.log(widget8.appName); // 返回 undefined
|
||||||
|
console.log(widget8.getName()); // 返回 “天气应用”
|
||||||
|
|
||||||
|
|
||||||
|
用Symbol创建私有属性
|
||||||
|
|
||||||
|
Symbol也是在ES6引入的一个新的数据类型,我们可以用它给对象的属性的键名命名。
|
||||||
|
|
||||||
|
同样我们来看一个例子。和上个例子相似,这里我们建立了一个块级作用域,但区别是我们把privateProps从WeakMap换成了Symbol来实现私有属性。
|
||||||
|
|
||||||
|
var WidgetI;
|
||||||
|
{
|
||||||
|
let privateProps = Symbol();
|
||||||
|
|
||||||
|
WidgetI = function(){
|
||||||
|
this[privateProps] = {appName : "天气应用"};
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetI.prototype.getName = function(){
|
||||||
|
return this[privateProps].appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var widget9 = new WidgetI();
|
||||||
|
console.log(widget9.getName()); // 返回 “天气应用”
|
||||||
|
|
||||||
|
|
||||||
|
如何创建静态属性?
|
||||||
|
|
||||||
|
前面我们提到了,静态的属性是属于构造函数的属性,而不是构造对象实例的属性。下面我们就来看看,如何通过JavaScript来实现静态属性。
|
||||||
|
|
||||||
|
创建公开静态属性
|
||||||
|
|
||||||
|
我们先看看如何通过static这个关键词来创建公开的静态属性。如以下代码所示,当我们直接在WidgetJ上面获取appName和getName的时候,可以看到结果是返回“天气应用”。而如果我们用WidgetJ构建一个widget10,看到返回的是未定义。这就说明,静态属性只能作用于class本身。
|
||||||
|
|
||||||
|
class WidgetJ {
|
||||||
|
static appName = "天气应用";
|
||||||
|
static getName(){
|
||||||
|
return this.appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(WidgetJ.appName); // 返回 “天气应用”
|
||||||
|
console.log(WidgetJ.getName()); // 返回 “天气应用”
|
||||||
|
|
||||||
|
var widget10 = new WidgetJ();
|
||||||
|
console.log(widget10.appName); // 返回 undefined
|
||||||
|
console.log(widget10.getName()); // 返回 undefined
|
||||||
|
|
||||||
|
|
||||||
|
创建私有静态属性
|
||||||
|
|
||||||
|
好,说完了公有静态属性,我们再来看看私有静态属性。私有的静态属性,顾名思义就是它不只是供构造者使用的,同时也是被封装在构建者之内的。
|
||||||
|
|
||||||
|
我们来看看它要如何实现,其实就是把 #符号和static关键词相加来使用。
|
||||||
|
|
||||||
|
class WidgetM {
|
||||||
|
static #appName = "天气应用";
|
||||||
|
static staticGetName(){
|
||||||
|
return WidgetM.#appName;
|
||||||
|
}
|
||||||
|
instanceGetName(){
|
||||||
|
return WidgetM.#appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(WidgetM.staticGetName()); // 返回 “天气应用”
|
||||||
|
|
||||||
|
var widget13 = new WidgetM();
|
||||||
|
console.log(widget13.instanceGetName()); // 返回 “天气应用”
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这节课我们通过对象内部的私有和静态属性,在第一讲的基础上,进一步地了解了对象的构建。同时,更重要的是,我们通过去掉私有属性的语法糖,也了解了如何通过函数式中的闭包、对象中的prototype、值类型中的Map和Symbol,这些更底层的方式实现同样的功能。在后面的两节课里,我们会继续从单个对象延伸到对象间的“生产关系”,来进一步理解面向对象的编程模式。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
我们今天尝试通过去掉语法糖,用更底层的方式实现了对象中的私有属性,那么你能不能自己动手试试去掉静态属性的语法糖,来实现类似的功能?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
343
专栏/JavaScript进阶实战课/08深入理解继承、Delegation和组合.md
Normal file
343
专栏/JavaScript进阶实战课/08深入理解继承、Delegation和组合.md
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
08 深入理解继承、Delegation和组合
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
关于面向对象编程,最著名的一本书就数GoF(Gang of Four)写的《设计模式:可复用面向对象软件的基础》了。这本书里一共提供了23种不同的设计模式,不过今天我们不会去展开了解这些细节,而是会把重点放在其中一个面向对象的核心思想上,也就是组合优于继承。
|
||||||
|
|
||||||
|
在JS圈,有不少继承和组合的争论。其实无论是继承还是组合,我们都不能忘了要批判性地思考。批判性思考的核心不是批判,而是通过深度思考核心问题,让我们对事物能有自己的判断。
|
||||||
|
|
||||||
|
所以,无论是继承还是组合,都只是方式、方法,它们要解决的核心问题就是如何让代码更加容易复用。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那么接下来,我们就根据这个思路,看看JavaScript中是通过哪些方法来解决代码复用这个问题的,以及在使用不同的方法时它们各自解决了什么问题、又引起了什么问题。这样我们在实际的业务场景中,就知道如何判断和选择最适合的解决方式了。
|
||||||
|
|
||||||
|
继承
|
||||||
|
|
||||||
|
在传统的OOP里面,我们通常会提到继承(Inheritance)和多态(Polymorphism)。继承是用来在父类的基础上创建一个子类,来继承父类的属性和方法。多态则允许我们在子类里面调用父类的构建者,并且覆盖父类里的方法。
|
||||||
|
|
||||||
|
那么下面,我们就先来看下在JavaScript里,要如何通过构建函数来做继承。
|
||||||
|
|
||||||
|
如何通过继承多态重用?
|
||||||
|
|
||||||
|
实际上,从ES6开始,我们就可以通过extends的方式来做继承。具体如下所示:
|
||||||
|
|
||||||
|
class Widget {
|
||||||
|
appName = "核心微件";
|
||||||
|
getName () {
|
||||||
|
return this.appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Calendar extends Widget {}
|
||||||
|
|
||||||
|
var calendar = new Calendar();
|
||||||
|
console.log(calendar.hasOwnProperty("appName")); // 返回 true
|
||||||
|
console.log(calendar.getName()); // 返回 "核心微件"
|
||||||
|
|
||||||
|
calendar.appName = "日历应用"
|
||||||
|
console.log(typeof calendar.getName); // 返回 function
|
||||||
|
console.log(calendar.getName()); // 返回 “日历应用”
|
||||||
|
|
||||||
|
|
||||||
|
接着来看多态。从ES6开始,我们可以通过super在子类构建者里面调用父类的构建者,并且覆盖父类里的属性。可以看到在下面的例子里,我们是通过super将Calendar的appName属性从“核心微件”改成了“日历应用”。
|
||||||
|
|
||||||
|
class Widget {
|
||||||
|
constructor() {
|
||||||
|
this.appName = "核心微件";
|
||||||
|
}
|
||||||
|
|
||||||
|
getName () {
|
||||||
|
return this.appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Calendar extends Widget {
|
||||||
|
constructor(){
|
||||||
|
super();
|
||||||
|
this.appName = "日历应用";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var calendar = new Calendar();
|
||||||
|
console.log(calendar.hasOwnProperty("appName")); // 返回 true
|
||||||
|
console.log(calendar.getName()); // 返回 "日历应用"
|
||||||
|
console.log(typeof calendar.getName); // 返回 function
|
||||||
|
console.log(calendar.getName()); // 返回 “日历应用”
|
||||||
|
|
||||||
|
|
||||||
|
在一些实际的例子,如React这样的三方库里,我们也经常可以看到一些继承的例子,比如我们可以通过继承React.Component来创建一个WelcomeMessage的子类。
|
||||||
|
|
||||||
|
class WelcomeMessage extends React.Component {
|
||||||
|
render() {
|
||||||
|
return <h1>Hello, {this.props.name}</h1>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
授权
|
||||||
|
|
||||||
|
说完了继承,我们再来看授权这个方法。
|
||||||
|
|
||||||
|
什么是授权(Delegation)呢?我打个比方,这里的授权不是我们理解的作为领导(父类)给下属(子类)授权,而是作为个体对象可以授权给一个平台或者他人来一起做一件事。
|
||||||
|
|
||||||
|
就好像我和极客时间合作,我的个人精力和专业能力只允许我尽量做好内容,但是我没有精力和经验去做编辑、后期和推广等等,这时就授权给极客时间相关的老师来一起做,我在这件事、这个过程中只是专心把内容的部分做好。
|
||||||
|
|
||||||
|
如何通过授权做到重用?
|
||||||
|
|
||||||
|
在前面的例子中,结合我们在第1讲里提到的基于原型链的继承,我们会发现使用JavaScript无论是通过函数构建也好,还是加了语法糖的类也好,来模拟一般的面向对象语言,比如Java的类和继承,对于有些开发者来说是比较反直觉的。在使用的时候需要大量的思想转换,才能把JavaScript的底层逻辑转换成实际呈现出来的实现。
|
||||||
|
|
||||||
|
那么有没有一种方式可以让代码更直观呢?这种方式其实就是通过原型本身来做授权会更符合直觉。从ES5开始,JavaScript就支持了Object.create()的方法。下面我们来看一个例子:
|
||||||
|
|
||||||
|
var Widget = {
|
||||||
|
setCity : function(City) {this.city = City; },
|
||||||
|
outputCity : function() {return this.city;}
|
||||||
|
};
|
||||||
|
|
||||||
|
var Weather = Object.create(Widget);
|
||||||
|
|
||||||
|
Weather.setWeather = function (City, Tempreture) {
|
||||||
|
this.setCity(City);
|
||||||
|
this.tempreture = Tempreture;
|
||||||
|
};
|
||||||
|
|
||||||
|
Weather.outputWeather = function() {
|
||||||
|
console.log(this.outputCity()+ ", " + this.tempreture);
|
||||||
|
}
|
||||||
|
|
||||||
|
var weatherApp1 = Object.create(Weather);
|
||||||
|
var weatherApp2 = Object.create(Weather);
|
||||||
|
|
||||||
|
weatherApp1.setWeather("北京","26度");
|
||||||
|
weatherApp2.setWeather("南京","28度");
|
||||||
|
|
||||||
|
weatherApp1.outputWeather(); // 北京, 26度
|
||||||
|
weatherApp2.outputWeather(); // 南京, 28度
|
||||||
|
|
||||||
|
|
||||||
|
可见,我们创建的Weather天气预报这个对象,授权给了Widget,让Widget在得到授权的情况下,帮助Weather来设定城市和返回城市。Widget对象在这里更像是一个平台,它在得到Weather的授权后为Weather赋能。而Weather对象可以在这个基础上,专注于实现自己的属性和方法,并且产出weatherApp1和weatherApp2的实例。
|
||||||
|
|
||||||
|
当然也有开发者认为class的方式没有什么反直觉的,那授权同样可以通过class来实现。比如我们如果想在上一讲提到过的集合(Set)和字典(Map)的基础上,加上计数的功能,可以通过继承Set来实现。但是我们也可以反之,在把部分功能授权给Map的基础上,自己专注实现一些类似Set的API接口。
|
||||||
|
|
||||||
|
class SetLikeMap {
|
||||||
|
// 初始化字典
|
||||||
|
constructor() { this.map = new Map(); }
|
||||||
|
// 自定义集合接口
|
||||||
|
count(key) { /*...*/ }
|
||||||
|
add(key) { /*...*/ }
|
||||||
|
delete(key) { /*...*/ }
|
||||||
|
// 迭代返回字典中的键
|
||||||
|
[Symbol.iterator]() { return this.map.keys(); }
|
||||||
|
// 部分功能授权给字典
|
||||||
|
keys() { return this.map.keys(); }
|
||||||
|
values() { return this.map.values(); }
|
||||||
|
entries() { return this.map.entries(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
组合
|
||||||
|
|
||||||
|
说完了授权,我们再来看看组合。当然上面我们说的授权,广义上其实就是一种组合。但是这种组合更像是“个体和平台的合作”;而另一种组合更像是“团队内部的合作”,它也有很多的应用和实现方式,我们可以来了解一下。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
如何通过借用做到重用?
|
||||||
|
|
||||||
|
在JavaScript中,函数有自带的apply和call功能。我们可以通过apply或call来“借用”一个功能。这种方式,也叫隐性混入(Implicit mixin)。比如在数组中,有一个原生的slice的方法,我们就可以通过call来借用这个原生方法。
|
||||||
|
|
||||||
|
如下代码示例,我们就是通过借用这个功能,把函数的实参当做数组来slice。
|
||||||
|
|
||||||
|
function argumentSlice() {
|
||||||
|
var args = [].slice.call(arguments, 1, 3);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
// example
|
||||||
|
argumentSlice(1, 2, 3, 4, 5, 6); // returns [2,3]
|
||||||
|
|
||||||
|
|
||||||
|
如何通过拷贝赋予重用?
|
||||||
|
|
||||||
|
除了“借力”以外,我们还能通过什么组合方式来替代继承呢?这就要说到“拷贝”了。这个方法顾名思义,就是把别人的属性和方法拷贝到自己的身上。这种方式也叫显性混入(Explicit mixin)。
|
||||||
|
|
||||||
|
在ES6之前,人们通常要偷偷摸摸地“抄袭”。在ES6之后,JavaScript里才增加了“赋予”,也就是Object.assign()的功能,从而可以名正言顺地当做是某个对象“赋予”给另外一个对象它的“特质和能力”。
|
||||||
|
|
||||||
|
那么下面,我们就先看看在ES6之后,JavaScript是如何名正言顺地来做拷贝的。
|
||||||
|
|
||||||
|
首先,通过对象自带的assign(),我们可以把Widget的属性赋予calendar,当然在calendar里,我们也可以保存自己本身的属性。和借用一样,借用和赋予都不会产生原型链。如以下代码所示:
|
||||||
|
|
||||||
|
var widget = {
|
||||||
|
appName : "核心微件"
|
||||||
|
}
|
||||||
|
|
||||||
|
var calendar = Object.assign({
|
||||||
|
appVersion: "1.0.9"
|
||||||
|
}, widget);
|
||||||
|
|
||||||
|
console.log(calendar.hasOwnProperty("appName")); // 返回 true
|
||||||
|
console.log(calendar.appName); // 返回 “核心微件”
|
||||||
|
console.log(calendar.hasOwnProperty("appVersion")); // 返回 true
|
||||||
|
console.log(calendar.appVersion); // 返回 “1.0.9”
|
||||||
|
|
||||||
|
|
||||||
|
好,接着我们再来看看在ES6之前,人们是怎么通过“抄袭”来拷贝的。
|
||||||
|
|
||||||
|
这里实际上分为“浅度拷贝”和“深度拷贝”两个概念。“浅度拷贝”类似于上面提到的赋予assign这个方法,它所做的就是遍历父类里面的属性,然后拷贝到子类。我们可以通过JavaScript中专有的for in循环,来遍历对象中的属性。
|
||||||
|
|
||||||
|
细心的同学可能会发现,我们在第2讲中说到用拷贝来做到不可变时,就了解过通过延展操作符来实现浅拷贝的方法了。
|
||||||
|
|
||||||
|
// 数组浅拷贝
|
||||||
|
var a = [ 1, 2 ];
|
||||||
|
var b = [ ...a ];
|
||||||
|
b.push( 3 );
|
||||||
|
a; // [1,2]
|
||||||
|
b; // [1,2,3]
|
||||||
|
|
||||||
|
// 对象浅拷贝
|
||||||
|
var o = {
|
||||||
|
x: 1,
|
||||||
|
y: 2
|
||||||
|
};
|
||||||
|
var p = { ...o };
|
||||||
|
p.y = 3;
|
||||||
|
o.y; // 2
|
||||||
|
p.y; // 3
|
||||||
|
|
||||||
|
|
||||||
|
而在延展操作符出现之前,人们大概可以通过这样一个for in循环做到类似的浅拷贝。
|
||||||
|
|
||||||
|
function shallowCopy(parent, child) {
|
||||||
|
var i;
|
||||||
|
child = child || {};
|
||||||
|
for (i in parent) {
|
||||||
|
if (parent.hasOwnProperty(i)) {
|
||||||
|
child[i] = parent[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
至于深度拷贝,是指当一个对象里面存在嵌入的对象就会深入遍历。但这样会引起一个问题:如果这个对象有多层嵌套的话,是每一层都要遍历吗?究竟多深算深?还有就是如果一个对象也引用了其它对象的属性,我们要不要也拷贝过来?
|
||||||
|
|
||||||
|
所以相对于深度拷贝,浅度拷贝的问题会少一些。但是在第2讲的留言互动区,我们也说过,如果我们想要保证一个对象的深度不可变,还是需要深度拷贝的。深度拷贝的一个相对简单的实现方案是用JSON.stringify。当然这个方案的前提是这个对象必须是JSON-safe的。
|
||||||
|
|
||||||
|
function deepCopy(o) { return JSON.parse(JSON.stringify(o)); }
|
||||||
|
|
||||||
|
|
||||||
|
同时,在第2讲的留言区中,也有同学提到过另外一种递归的实现方式,所以我们也大致可以通过这样一个递归来实现:
|
||||||
|
|
||||||
|
function deepCopy(parent, child) {
|
||||||
|
var i,
|
||||||
|
toStr = Object.prototype.toString,
|
||||||
|
astr = "[object Array]";
|
||||||
|
child = child || {};
|
||||||
|
for (i in parent) {
|
||||||
|
if (parent.hasOwnProperty(i)) {
|
||||||
|
if (typeof parent[i] === "object") {
|
||||||
|
child[i] = (toStr.call(parent[i]) === astr) ? [] : {};
|
||||||
|
deepCopy(parent[i], child[i]);
|
||||||
|
} else {
|
||||||
|
child[i] = parent[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
如何通过组合做到重用?
|
||||||
|
|
||||||
|
上面我们说的无论是借用、赋予,深度还是浅度拷贝,都是一对一的关系。最后我们再来看看,如何通过ES6当中的assign来做到组合混入,也就是说把几个对象的属性都混入在一起。其实方法很简单,以下是参考:
|
||||||
|
|
||||||
|
var touchScreen = {
|
||||||
|
hasTouchScreen : () => true
|
||||||
|
};
|
||||||
|
|
||||||
|
var button = {
|
||||||
|
hasButton: () => true
|
||||||
|
};
|
||||||
|
var speaker = {
|
||||||
|
hasSpeaker: () => true
|
||||||
|
};
|
||||||
|
|
||||||
|
const Phone = Object.assign({}, touchScreen, button, speaker);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
hasTouchScreen: ${ Phone.hasChocolate() }
|
||||||
|
hasButton: ${ Phone.hasCaramelSwirl() }
|
||||||
|
hasSpeaker: ${ Phone.hasPecans() }
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
React中的组合优于继承
|
||||||
|
|
||||||
|
在React当中,我们也可以看到组合优于继承的无处不在,并且它同样体现在我们前面讲过的两个方面,一个是“团队内部的合作”,另一个是“个体与平台合作”。下面,我们先看看“团队内部的合作”的例子,在下面的例子里,WelcomeDialog就是嵌入在FancyBorder中的一个团队成员。
|
||||||
|
|
||||||
|
function FancyBorder(props) {
|
||||||
|
return (
|
||||||
|
<div className={'FancyBorder FancyBorder-' + props.color}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WelcomeDialog() {
|
||||||
|
return (
|
||||||
|
<FancyBorder color="blue">
|
||||||
|
<h1 className="Dialog-title">
|
||||||
|
Welcome
|
||||||
|
</h1>
|
||||||
|
<p className="Dialog-message">
|
||||||
|
Thank you for visiting our spacecraft!
|
||||||
|
</p>
|
||||||
|
</FancyBorder>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
另外,我们也可以看到“个体与平台合作”的影子。在这里,WelcomeDialog是一个“专业”的Dialog,它授权给Dialog这个平台,借助平台的功能,实现自己的title和message。这里就是用到了组合。
|
||||||
|
|
||||||
|
function Dialog(props) {
|
||||||
|
return (
|
||||||
|
<FancyBorder color="blue">
|
||||||
|
<h1 className="Dialog-title">
|
||||||
|
{props.title}
|
||||||
|
</h1>
|
||||||
|
<p className="Dialog-message">
|
||||||
|
{props.message}
|
||||||
|
</p>
|
||||||
|
</FancyBorder>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function WelcomeDialog() {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title="Welcome"
|
||||||
|
message="Thank you for visiting our spacecraft!" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这节课,我们了解了通过JavaScript做到代码复用的几种核心思想和方法,从传统的继承,到JavaScript特色的授权以及组合等方式都有分析。虽然我说授权和组合优于继承,但实际上它们之间的关系不是非黑即白的。
|
||||||
|
|
||||||
|
我们看到在前端圈,有很多大佬比如道格拉斯·克罗克福德(Douglas Crockford)和凯尔·辛普森(Kyle Simpson),都是基于授权的对象创建的积极拥护者;而像阿克塞尔·劳施迈尔博士(Dr. Axel Rauschmayer)则是基于类的对象构建的捍卫者。
|
||||||
|
|
||||||
|
我们作为程序员,如果对对象和面向对象的理解不深入,可能很容易在不同的论战和观点面前左摇右摆。而实际的情况是,真理本来就不止一个。我们要的“真理”,只不过是通过一个观察角度,形成的一个观点。这样,才能分析哪种方式适合我们当下要解决的问题。这个方式,只有在当下,才是“真理”。而我们通过这个单元整理的方法,目的就是帮助我们做到这样的观测。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
在前面一讲中,我们试着通过去掉对象私有属性的语法糖,来看如何用更底层的语言能力来实现类似的功能。那么,今天你能尝试着实现下JS中的类和继承中的super,以及原型和授权中的Object.create()吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
269
专栏/JavaScript进阶实战课/09面向对象:通过词法作用域和调用点理解this绑定.md
Normal file
269
专栏/JavaScript进阶实战课/09面向对象:通过词法作用域和调用点理解this绑定.md
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
09 面向对象:通过词法作用域和调用点理解this绑定
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
今天,我们来讲讲JavaScript中的this。其实讲this的资料有很多,其中不少已经把这个概念讲的很清楚了。但是为了课程的系统性,我今天也从这个单元咱们讲到的对象和面向对象的角度来说一说。
|
||||||
|
|
||||||
|
因为现在正好赶上国庆假期,咱们这节课的内容不是很长,所以你学起来也不会很辛苦。但是字少事大,this的概念还是很重要的。所以如果你之前没有具体了解过,还是希望这节课能帮助你更好地理解this。
|
||||||
|
|
||||||
|
从直观的印象来看,你可能觉得 this 指的是函数本身或它所在的范围,其实这样的理解都是不对。在JavaScript中,this 是在运行时而不是编写时绑定的。所以要正确地使用它,需要考虑到函数调用时的执行上下文。
|
||||||
|
|
||||||
|
默认绑定
|
||||||
|
|
||||||
|
我们来看一个简单的例子,在下面的例子中,a 是在全局定义的,aLogger的函数是在全局被调用的,所以返回的this就是全局上下文,所以a的值自然就是2。
|
||||||
|
|
||||||
|
function aLogger() {
|
||||||
|
console.log( this.a );
|
||||||
|
}
|
||||||
|
var a = 2;
|
||||||
|
aLogger(); // 2
|
||||||
|
|
||||||
|
|
||||||
|
这种默认的绑定只在非strict mode的情况下是可以的。所以如果在strict mode下,这种默认的的绑定是不可以的,则会返回 TypeError: this is undefined。
|
||||||
|
|
||||||
|
隐式绑定
|
||||||
|
|
||||||
|
下面,我们再来看看,如果我们在一个对象 obj 里给 a 赋值为 3,然后我们通过调用 aLogger 来获取 a 的值,这个时候,aLogger 被调用时的上下文是在 obj 中,所以它的值就是 3。
|
||||||
|
|
||||||
|
function aLogger() {
|
||||||
|
console.log( this.a );
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj = {
|
||||||
|
a: 3,
|
||||||
|
logger: aLogger
|
||||||
|
};
|
||||||
|
|
||||||
|
var a = 2;
|
||||||
|
|
||||||
|
obj.logger(); // 3
|
||||||
|
|
||||||
|
|
||||||
|
但是隐式绑定也有它的问题,就是当我们把对象里的方法赋值给一个全局变量时,这种绑定就消失了。比如下面的例子中,我们给 objLogger 赋值 obj.logger,结果 this 引用的就是全局中 a 的值。
|
||||||
|
|
||||||
|
function logger() {
|
||||||
|
console.log( this.a );
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj = {
|
||||||
|
a: 3,
|
||||||
|
logger: logger
|
||||||
|
};
|
||||||
|
|
||||||
|
var a = 2;
|
||||||
|
|
||||||
|
var objLogger = obj.logger;
|
||||||
|
|
||||||
|
objLogger(); // 2
|
||||||
|
|
||||||
|
|
||||||
|
显式绑定
|
||||||
|
|
||||||
|
下面,我们再来看看显式绑定。在这种情况下,我们使用的是 call 或者 apply。通过这种方式,我们可以强行使 this 等于 obj。
|
||||||
|
|
||||||
|
function logger() {
|
||||||
|
console.log( this.a );
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj = {
|
||||||
|
a: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.call( obj ); // 3
|
||||||
|
|
||||||
|
|
||||||
|
这种显式绑定也不能完全解决问题,它也会产生一些副作用,比如在通过 wrapper 包装的 new String,new Boolean 或 new Number 的时候,这种绑定就会消失。
|
||||||
|
|
||||||
|
硬性绑定
|
||||||
|
|
||||||
|
下面,我们再来看看一种硬性绑定的方式。这里,我们使用从ES5开始支持的 bind 来绑定,通过这种方式,无论后续我们怎么调用 hardBinding 函数,logger 都会把 obj 当做 this 来获取它的 a 属性的值。
|
||||||
|
|
||||||
|
function logger() {
|
||||||
|
console.log( this.a );
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj = {
|
||||||
|
a: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
var hardBinding = logger.bind( obj );
|
||||||
|
|
||||||
|
setTimeout( hardBinding, 1000 ); // 3
|
||||||
|
|
||||||
|
hardBinding.call( window ); // 3
|
||||||
|
|
||||||
|
|
||||||
|
new绑定
|
||||||
|
|
||||||
|
最后,我们再来看看new 绑定,当我们使用 new 创建一个新的实例的时候,这个新的对象就是 this,所以我们可以看到在新的实例中我们传入的 2,就可以给 loggerA 实例的属性 a 赋值为 a,所以返回的结果是 2。
|
||||||
|
|
||||||
|
function logger(a) {
|
||||||
|
this.a = a;
|
||||||
|
console.log( this.a );
|
||||||
|
}
|
||||||
|
|
||||||
|
var loggerA = new logger( 2 ); // 2
|
||||||
|
|
||||||
|
|
||||||
|
下面我们来看一个“硬碰硬”的较量,我们来试试用hard binding 来对决 new binding,看看谁拥有绝对的实力。下面,我们先将 logger 里的 this 硬性绑定到obj 1上,这时我们输出的结果是2。然后,我们用 new 来创建一个新的 logger 实例,在这个实例中,我们可以看到 obj 2 作为新的 logger 实例,它的 this 是可以不受 obj 1 影响的。所以new是强于hard binding的。
|
||||||
|
|
||||||
|
function logger(a) {
|
||||||
|
this.a = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj1 = {};
|
||||||
|
|
||||||
|
var hardBinding = logger.bind( obj1 );
|
||||||
|
|
||||||
|
hardBinding( 2 );
|
||||||
|
|
||||||
|
console.log( obj1.a ); // 2
|
||||||
|
|
||||||
|
var obj2 = new logger( 3 );
|
||||||
|
|
||||||
|
console.log( obj1.a ); // 2
|
||||||
|
console.log( obj2.a ); // 3
|
||||||
|
|
||||||
|
|
||||||
|
之前在评论区也有朋友提到过谋智,也就是开发了火狐浏览器的公司,运营的一个MDN网站是一个不错的辅助了解JavaScript的平台。通过在MDN上的 bind polyfill 的代码,我们大概可以看到在 bind 中是有一个逻辑判断的,它会看新的实例是不是通过 new 来创建的,如果是,那么 this 就绑定到新的实例上。
|
||||||
|
|
||||||
|
this instanceof fNOP &&
|
||||||
|
oThis ? this : oThis
|
||||||
|
|
||||||
|
// ... and:
|
||||||
|
|
||||||
|
fNOP.prototype = this.prototype;
|
||||||
|
fBound.prototype = new fNOP();
|
||||||
|
|
||||||
|
|
||||||
|
那么我们对比 new 和 bind 各有什么好处呢?用 new 的好处是可以帮助我们忽略 hard binding,同时可以预设函数的实参。用 bind 的好处是任何 this 之后的实参,都可以当做是默认的实参。这样就可以用来创建我们之前第3讲说过的柯理式中的部分应用。比如在下面的例子中,1 和 2 就作为默认实参,在 partialFunc 中我们只要输入 9,就可以得到3个数字相加的结果。
|
||||||
|
|
||||||
|
function fullFunc (x, y, z) {
|
||||||
|
return x + y + z;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partialFunc = fullFunc.bind(this, 1, 2);
|
||||||
|
partialFunc(9); // 12
|
||||||
|
|
||||||
|
|
||||||
|
除了硬性绑定外,还有一个软性绑定的方式,它可以在 global 或 undefined 的情况下,将 this 绑定到一个默认的 obj 上。
|
||||||
|
|
||||||
|
if (!Function.prototype.softBind) {
|
||||||
|
Function.prototype.softBind = function(obj) {
|
||||||
|
var fn = this,
|
||||||
|
curried = [].slice.call( arguments, 1 ),
|
||||||
|
bound = function bound() {
|
||||||
|
return fn.apply(
|
||||||
|
(!this ||
|
||||||
|
(typeof window !== "undefined" &&
|
||||||
|
this === window) ||
|
||||||
|
(typeof global !== "undefined" &&
|
||||||
|
this === global)
|
||||||
|
) ? obj : this,
|
||||||
|
curried.concat.apply( curried, arguments )
|
||||||
|
);
|
||||||
|
};
|
||||||
|
bound.prototype = Object.create( fn.prototype );
|
||||||
|
return bound;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
在下面的例子当中,我们可以看到,除隐式、显式和软性绑定外,obj2 在 timeout 全局作用域下,返回的默认绑定结果。
|
||||||
|
|
||||||
|
function logger() {
|
||||||
|
console.log("name: " + this.name);
|
||||||
|
}
|
||||||
|
var obj1 = { name: "obj1" },
|
||||||
|
obj2 = { name: "obj2" },
|
||||||
|
obj3 = { name: "obj3" };
|
||||||
|
|
||||||
|
var logger1 = logger.softBind( obj1 );
|
||||||
|
logger1(); // name: obj1
|
||||||
|
|
||||||
|
obj2.logger = logger.softBind( obj1 );
|
||||||
|
obj2.logger(); // name: obj2
|
||||||
|
|
||||||
|
logger1.call( obj3 ); // name: obj3
|
||||||
|
|
||||||
|
setTimeout( obj2.logger, 1000 ); // name: obj1
|
||||||
|
|
||||||
|
|
||||||
|
同样地,这样的软性绑定也支持我们前面说的柯理式中的部分应用。
|
||||||
|
|
||||||
|
function fullFunc (x, y, z) {
|
||||||
|
return x + y + z;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partialFunc = fullFunc.softBind(this, 1, 2);
|
||||||
|
partialFunc(9); // 12
|
||||||
|
|
||||||
|
|
||||||
|
延伸:箭头函数
|
||||||
|
|
||||||
|
在 this 的绑定中,有一点是需要我们注意的,那就是当我们使用箭头函数的时候,this 是在词法域里面的,而不是根据函数执行时的上下文。比如在下面的例子中,我们看到返回的结果就是 2 而不是3。
|
||||||
|
|
||||||
|
function logger() {
|
||||||
|
return (a) => {
|
||||||
|
console.log( this.a );
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var obj1 = {
|
||||||
|
a: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
var obj2 = {
|
||||||
|
a: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
var logger1 = logger.call( obj1 );
|
||||||
|
|
||||||
|
logger1.call( obj2 ); // 2
|
||||||
|
|
||||||
|
|
||||||
|
通过箭头函数来做 this 绑定的一个比较常用的场景就是setTimeout。在这个函数中的 this 就会绑定在 logger 的函数词法域里。
|
||||||
|
|
||||||
|
function logger() {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log( this.a );
|
||||||
|
},1000);
|
||||||
|
}
|
||||||
|
var obj = {
|
||||||
|
a: 2
|
||||||
|
};
|
||||||
|
logger.call( obj ); // 2
|
||||||
|
|
||||||
|
|
||||||
|
如果我们不用箭头函数的话,也可以通过 self = this 这样的方式将 this 绑定在词法域里。
|
||||||
|
|
||||||
|
function logger() {
|
||||||
|
var self = this;
|
||||||
|
setTimeout( function(){
|
||||||
|
console.log( self.a );
|
||||||
|
}, 1000 );
|
||||||
|
}
|
||||||
|
var obj = {
|
||||||
|
a: 2
|
||||||
|
};
|
||||||
|
logger.call( obj ); // 2
|
||||||
|
|
||||||
|
|
||||||
|
但是通常为了代码的可读性和可维护性,在同一个函数中,应该一以贯之,要么尽量使用词法域,干脆不要有 this;或者要用 this,就通过 bind 等来绑定,而不是通过箭头函数或者 self = this 这样的“奇技淫巧”来做绑定。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这节课我们学习了 this 的绑定,它可以说是和函数式中的 closure 有着同等重要性的概念。如果说函数式编程离不开对 closure 的理解,那么不理解 this,在 JavaScript 中用面向对象编程也会一头雾水。这两个概念虽然理解起来比较绕脑,但是一旦理解,你就会发现它们的无处不在。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
我们今天在讲 this 的绑定时,用到了 call 和 bind,我们知道 JavaScript 中和 call 类似的还有 apply,那么你觉得在处理绑定时,它和 call 效果一样吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
341
专栏/JavaScript进阶实战课/10JS有哪8种数据类型,你需要注意什么?.md
Normal file
341
专栏/JavaScript进阶实战课/10JS有哪8种数据类型,你需要注意什么?.md
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
10 JS有哪8种数据类型,你需要注意什么?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
JavaScript的数据类型看上去不难理解,但是你确定真的理解并且会用它们吗?实际上,如果不系统地理解数据类型的概念,很可能会导致一些隐藏问题时不时地冒出来,给我们写的程序挖坑。比如简单的加法计算可能就会带来意想不到的结果值;或者没有很好地利用到JS的一些长处,让开发更便捷,比如说通过内置的包装对象快速获取值的属性。
|
||||||
|
|
||||||
|
在正式开始之前,我还是要先说明一下,虽然现在JS介绍数据类型的书和资料不在少数,我们也不会在这里做理论赘述。但我还是会先带你快速建立起对值的基本认识框架,然后通过对问题的深入了解,以此达成扬长避短的目标。
|
||||||
|
|
||||||
|
那么,JavaScript当中有几种类型的值呢?答案是8种。如果再归归类,我们还可以把它们分为两大类,分别是原始类型(Primitive Type)和对象类型(Object Type)。
|
||||||
|
|
||||||
|
其中,原始数据类型包含了数字、布尔、字符串、BigInt、null、undefined,以及后来新增的symbol,这个数据类型的值都是不可变的(immutable)。
|
||||||
|
|
||||||
|
对象数据类型则包含我们经常说的对象,对象的值是可变的(mutable)。它是一个大类,如果再细分,它又包含了我们常用的数组(array)、函数(function)、Date、RegExp,以及后来新增的Map和Set。没错,我们经常用的数组、函数作为值都属于对象数据类型。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
好,在了解了数据类型的划分后,接下来,我们会通过不同类型在实际应用时会出现的各种问题,来进一步了解它们的原理和使用方法,打下底层基础,为后面的学习铺平道路。
|
||||||
|
|
||||||
|
下面,我们就从原始类型数据开始说起。
|
||||||
|
|
||||||
|
原始类型
|
||||||
|
|
||||||
|
原始类型是我们经常用到的数据类型,这里有基础的类型,比如数字、字符串和布尔,也有特殊的类型,比如null和undefined。相比对象类型,原始类型数据从值的角度来看是理论和实际偏差最大的部分,另外因为它看上去比较简单,所以也同时是很容易被忽略的部分。
|
||||||
|
|
||||||
|
所以,我们这节课的重点,就是要把这些问题用放大镜放大,深入浅出地了解它的核心原理和解决方案。
|
||||||
|
|
||||||
|
number数字:为什么0.1+0.2不等于0.3?
|
||||||
|
|
||||||
|
我们先来做个小实验:在Chrome开发者工具中输入0.1+0.2,得到的结果是什么?惊不惊喜,意不意外?最后的结果竟然不是0.3,后面还有\(4 \\times 10^{-17}\)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
为什么会出现这样的情况?我们可以先回头看下这个数据类型脑图,JavaScript中的数字类型包括了两类:浮点数和整数。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
而以上运算使用的就是浮点数。那和浮点数对应的是什么呢?是定点数。
|
||||||
|
|
||||||
|
定点数的好处是可以满足日常的小额计算。但它也有很大的缺点,就是在进行很小或很大的数字计算时,会浪费很大的空间。比如我们想表达中国有14亿人口, 如果写起来,就是1,400,000,000,14后面8个零。所以要解决这个问题,就需要用到科学计数法。
|
||||||
|
|
||||||
|
浮点数是采用科学计数法来表示的,由尾数(significand mantissa)、基数(base)和指数(exponent)三部分组成。上面的数字如果是用科学计数法表示,只需要写成\(1.4 \\times 10^{9}\)即可。对于小数,就是反之,拿0.002举例,数字是2,那么小数就是10的负3次方。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
上面我们看的是十进制的例子,而JavaScript所采用的IEEE 754 是二进制浮点数算术标准。这个标准里规定了4种浮点数算术方式:单精确度、双精确度、延伸单精确度与延伸双精确度。JavaScript在这里选择的又是双精确度(64位)这种方式,通常也叫double或者float64类型。
|
||||||
|
|
||||||
|
这种方式顾名思义,有64位比特。其中包含了1个比特的符号位(sign)、11个比特的有偏指数(exponent)、还有52个比特的小数部分(fraction)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
因为把十进制转化为二进制的算法是用十进制的小数乘以2直到没有了小数为止,所以十进制下的有些小数无法被精确地表示成二进制小数。而既然这里的浮点数是二进制,因此小数就会存在精度丢失的问题。
|
||||||
|
|
||||||
|
而且当我们使用加减法的时候,由于需要先对齐(也就是把指数对齐,过程中产生移位),再计算,所以这个精度会进一步丢失。并且根据JavaScript引擎实际返回的小数点后的位数,可能会出现第三次丢失。这样下来,最后的结果就和实际相加减的数有偏离。
|
||||||
|
|
||||||
|
现在,我们就了解了精度丢失的问题,这并不是一个bug,而是符合标准设计的。同时,我们也了解了出现这种情况的原因和实际运算的步骤,那我们需要做什么才能解决由它引起的精度问题呢?
|
||||||
|
|
||||||
|
通常对于这个问题的处理,是通过按比例放大再缩小。我们来看个例子,假如要设置一个19.99元的商品,我们可以把它先变成1999,这样就可以做加法计算,之后再缩小。
|
||||||
|
|
||||||
|
var priceBigInt = 1999n;
|
||||||
|
var priceStr = String(priceBigInt);
|
||||||
|
var priceYuan = `¥${priceStr.slice(0,-2)}.${priceStr.slice(-2)}`;
|
||||||
|
console.log(priceYuan);
|
||||||
|
|
||||||
|
|
||||||
|
NaN:如何判断一个值是不是数字?
|
||||||
|
|
||||||
|
如果说浮点数给我们带来了意料之外、情理之中的惊喜,那么NaN带给我们的则是意料之外且情理之外的惊喜了。
|
||||||
|
|
||||||
|
|
||||||
|
惊喜1-
|
||||||
|
在IEEE 754中,NaN虽然代表的是“不是数字”的意思,但是如果我们用typeof NaN来获取,会发现它返回的是number。
|
||||||
|
|
||||||
|
惊喜2-
|
||||||
|
原始类型有个特点,就是两个数据的数值一样,会被当做是等同的。而对象类型则相反,即使两个数据的数值一样,也会被当做是不同的数值,每一个数值都有一个唯一的身份。
|
||||||
|
|
||||||
|
|
||||||
|
我们可以通过下面的例子来看一下。当我们严格比较两个数字时,返回的就是true;当我们严格比较两个对象字面量时,返回的结果就是false。
|
||||||
|
|
||||||
|
123 === 123 // 返回 true
|
||||||
|
{} === {} // 返回 false
|
||||||
|
|
||||||
|
|
||||||
|
按照这样的原则,既然NaN是数字的值,如果我们输入NaN严格比较NaN,原则上应该返回的是true,可实际NaN返回的却是false。
|
||||||
|
|
||||||
|
NaN === NaN // 返回 false
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
惊喜3-
|
||||||
|
JavaScript中会通过isNaN来判断一个值是不是数字,但是当我们输入一个字符串,它也会被当做是一个数字。因为在这个过程中,“0”这个字符串被转换成了数字。
|
||||||
|
|
||||||
|
|
||||||
|
isNaN("0") // 返回 false
|
||||||
|
|
||||||
|
|
||||||
|
所以,从这些惊喜中我们可以发现,想通过NaN和isNaN来判断一个值是不是数字的做法,是很不靠谱的。
|
||||||
|
|
||||||
|
那么,如何才能更正确地判断一个值是不是数字呢?
|
||||||
|
|
||||||
|
我们可以通过判断值的类型,并加上一个isFinite这种方式来判断。isFinite是JavaScript中的一个内置函数,通过它,我们可以过滤掉NaN和Infinity。
|
||||||
|
|
||||||
|
但是要注意,和惊喜3一样,它会把括号中的值比如字符串转化成数字,所以我们需要再通过typeof来确保这种被转换的问题不会被漏掉。
|
||||||
|
|
||||||
|
var isNum = function isNum(value){
|
||||||
|
return typeof value === 'number' && isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
string字符串:一串字符有多长?
|
||||||
|
|
||||||
|
我们知道,原始类型的数据除了undefined和null以外,都有内置的包装对象(object wrapper)。那么下面就让我们通过字符串,来看一下它是怎么工作的。
|
||||||
|
|
||||||
|
可以看到在这个例子中,我们是用new String()这样的constructor的方式创建一个字符串。而当我们想要获取它的长度时,就可以采用str.length方法来获取。
|
||||||
|
|
||||||
|
var str = new String("hello");
|
||||||
|
str.length // 返回 5;
|
||||||
|
typeof str // 返回 'object'
|
||||||
|
|
||||||
|
|
||||||
|
但是,即使你不用constructor这种方式,也仍然可以用字面量的方式来获取字符串的长度(length)。在这个过程中,你同样可以看到长度结果的返回。而且,当你再用typeof来获取它的类型时,收到的结果仍然是字符串,而不是对象。
|
||||||
|
|
||||||
|
这是因为在你使用length这个方法的时候,JavaScript引擎临时创建了一个字符串的包装对象。当这个字符串计算出长度后,这个对象也就消失了。所以当你再回过头来看str的类型时,返回的是字符串而不是对象。
|
||||||
|
|
||||||
|
var str = "hello";
|
||||||
|
str.length // 返回 5
|
||||||
|
typeof str // 返回 'string'
|
||||||
|
|
||||||
|
|
||||||
|
boolean布尔:你分得清真假吗?
|
||||||
|
|
||||||
|
在Java中,布尔类型的数据包含了真值true和假值false两个值。但需要注意的是,在JavaScript中,除了false以外,undefined、null、0、NaN和“‘’”也都是假值。
|
||||||
|
|
||||||
|
这里你一定会问,那么真值有哪些呢?其实你可以使用排除法,除了假值以外的,都可以认为是真值。为了方便查询,你可以参考下面这个列表。-
|
||||||
|
|
||||||
|
|
||||||
|
null:什么,你是个对象?
|
||||||
|
|
||||||
|
我们前面说过,null是六种原始数据类型中独立的一种。可当我们用typeof来获取null的种类时,返回的结果是’object’,也就是说它是属于对象类型。
|
||||||
|
|
||||||
|
这是一个bug,但是也并非完全没有逻辑的bug,因为null实际是一个空的对象指针。
|
||||||
|
|
||||||
|
那我们要如何判断一个值是不是null呢?解决这个问题方法,其实就是不用typeof,而是直接将值和null做严格比较。
|
||||||
|
|
||||||
|
除了null以外,另外一个和它类似的是undefined。如果说null代表值是空对象,undefined代表的就是没有值。但是当我们对比它们的值时,它们却是相等的;另外严格比较它们的数据类型的时候,又会发现它们是不同的。
|
||||||
|
|
||||||
|
null == undefined // 返回 true
|
||||||
|
null === undefined // 返回 false
|
||||||
|
|
||||||
|
|
||||||
|
所以,我们判断值是否为空,可以是 if (x === undefined || x === null) {},也可以是 if (!x) {…}。
|
||||||
|
|
||||||
|
那么我们什么时候用undefined,什么时候用null呢?通常我们是不用undefined的,而是把它作为系统的返回值或系统异常。比如当我们声明了一个变量,但是没有赋值的情况下,结果就是undefined。而当我们想特意定义一个空的对象的时候,可以用null。
|
||||||
|
|
||||||
|
var a;
|
||||||
|
a // undefined
|
||||||
|
var b = null;
|
||||||
|
|
||||||
|
if (b != null){
|
||||||
|
// do something!
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
对象类型
|
||||||
|
|
||||||
|
说完了原始类型,我们再来看看对象类型。原始类型的问题都是非黑即白的,比如我们前面看到的问题都是由于JavaScript设计中的某种限制、缺陷或bug造成的。
|
||||||
|
|
||||||
|
而对象类型的问题,更多是在不同场景下、不同的使用方式所体现出的优劣势。
|
||||||
|
|
||||||
|
为什么基于对象创建的实例instanceOf返回错误?
|
||||||
|
|
||||||
|
你要创建一个对象,既可以通过字面量也可以通过constructor的模式(我们在后面讲设计范式的时候还会更具体地提到这一点,这里我们只需要了解它的不同使用方式)。但这里你需要注意的问题是,如果你进一步基于一个对象创建实例,并且用到我们之前讲面向对象编程模式中提到的Object.create()的话,这样的情况下,你没法用instanceOf来判断新的实例属于哪个对象。
|
||||||
|
|
||||||
|
因为这里的两个对象间更像是授权而不是继承的关系,之间没有从属关系,所以返回的是错误。而通过经典的基于原型的继承方式创建的实例,则可以通过instanceOf获取这种从属关系。
|
||||||
|
|
||||||
|
这里我们可以来对比下字面量,constructor以及基于原型的继承的使用,具体你可以参考以下代码示例:
|
||||||
|
|
||||||
|
// 方式1:字面量
|
||||||
|
var objA = {name: "Object A"};
|
||||||
|
var objB = Object.create(objA);
|
||||||
|
console.log(objB instanceof objA); // 返回 类型错误
|
||||||
|
|
||||||
|
// 方式2:constructor
|
||||||
|
var objA = new Object();
|
||||||
|
objA.name = "Object A";
|
||||||
|
var objB = Object.create(objA);
|
||||||
|
console.log(objB instanceof objA); // 返回 类型错误
|
||||||
|
|
||||||
|
// 经典的基于原型的继承
|
||||||
|
var objA = function() {
|
||||||
|
/* more code here */
|
||||||
|
}
|
||||||
|
objB = new objA();
|
||||||
|
console.log(objB instanceof objA); // 返回 true
|
||||||
|
|
||||||
|
|
||||||
|
其实,不光是对象,数组和函数也都可以不用constructor,而是通过字面量的方式创建。反之,我们说的数字、字符串和布尔除了字面量,也都可以通过constructor创建。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
我们知道,原始类型是不可改变的,而对象类型则是可变的。比如下面这个例子,当定义一个字符串,通过 toUpperCase() 把它变成大写后,再次获取它的值时,会发现其仍然是小写。
|
||||||
|
|
||||||
|
var str = "hello";
|
||||||
|
str.toUpperCase(); // 返回 HELLO
|
||||||
|
str; // 返回 hello
|
||||||
|
|
||||||
|
|
||||||
|
而如果我们尝试给一个对象增加一个属性,那么再次获取它的属性的时候,其属性就会改变。
|
||||||
|
|
||||||
|
var obj = { vehicle: "car" };
|
||||||
|
obj.vehicle= "bus";
|
||||||
|
console.log (obj.vehicle); // 返回 bus
|
||||||
|
|
||||||
|
|
||||||
|
另外一点需要注意的是,对象数据在栈中只被引用,而实际存放在堆中。举个例子,假如我们有两个变量,变量personA赋值为{name: “John”, age:25},当我们将personB赋值为personA,然后修改personA的名称,那么personB的名字也会改。因为它们引用的都是堆中的同一个对象。
|
||||||
|
|
||||||
|
var personA = {
|
||||||
|
name: "John",
|
||||||
|
age:25
|
||||||
|
};
|
||||||
|
var personB = personA;
|
||||||
|
|
||||||
|
personB.name = "Jack";
|
||||||
|
|
||||||
|
personA.name; // 返回 Jack
|
||||||
|
personB.name; // 返回 Jack
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
如何识别一个数组?
|
||||||
|
|
||||||
|
前面我们说过,数组其实是对象,那我们怎么能判断一个值是数组还是对象呢?其实就是我刚刚提到过很多次的typeof,它可以帮我们了解一个“值”的种类。当我们用typeof来获取对象和数组的种类时,返回的都是object。
|
||||||
|
|
||||||
|
但是,前面我们也说过,null返回的也是object,这样我们用typeof是没有办法来实际判断一个值是不是对象的。不过,因为null是一个假值,所以我们只要加一个真假判断,和typeof结合起来,就可以排除null,来判断是否一个值实际是不是对象。
|
||||||
|
|
||||||
|
if (myVal && typeof myVal == 'object') {
|
||||||
|
// myVal 是一个对象或数组
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
好,上面的例子能筛选对象了,可我们仍然没法判断一个对象是不是数组。
|
||||||
|
|
||||||
|
其实,在ES5以后,JavaScript就有了isArray的内置功能,在此之前,人们都是通过手写的方式来判断一个值是不是数组。但在这儿,为了更好地理解原理,我们可以在上面例子的基础上,做一个isArray的功能。
|
||||||
|
|
||||||
|
这里我们用到了数组的特性,数组虽然从数据类型上看也是一种对象,但是它和对象值相比,区别在于是否可以计算长度。
|
||||||
|
|
||||||
|
if (myVal && typeof myVal === "object" &&
|
||||||
|
typeof myVal.length === "number"
|
||||||
|
&& !(myVal.propertyIsEnumerable("length"))) {
|
||||||
|
console.log("yes");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
除此之外,我们也可以用在前面讲到面向对象时,讲到的“基于原型的继承”中学过的原型来判断。
|
||||||
|
|
||||||
|
if (typeof Array.isArray === 'undefined') {
|
||||||
|
Array.isArray = function (arg) {
|
||||||
|
return Object.prototype.toString.call(arg) === "[object Array]";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function函数字面量:是声明还是表达?
|
||||||
|
|
||||||
|
函数在JavaScript中主要有两种写法,表达式和声明式。
|
||||||
|
|
||||||
|
|
||||||
|
声明式函数
|
||||||
|
|
||||||
|
|
||||||
|
我们先来看看在JavaScript中,什么是声明,都有哪些形式的声明。
|
||||||
|
|
||||||
|
如下图所示,我们大致把声明分为了变量、常量、函数和类这四种类型。从中可以看出,这四大类声明几乎都是我们接触过的常用的语句,比如变量、常量和函数都是我们在讲到函数式和不可变时有提到过的,类则是我们在面向对象编程时讲过的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
说完声明,我们再看看声明式函数。声明式函数最大的好处就是可以用于函数提升hoisting。可以说,除了有特殊需求会用到表达式以外,声明式就是默认的写法,下面是一个声明式函数的抽象语法树AST(abstract syntax tree)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function a = {} // 声明式
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
表达式函数
|
||||||
|
|
||||||
|
|
||||||
|
下面我们再来看看表达式函数。首先同样的问题,什么是表达式?一个语句里面可以有很多的表达,而表达式函数其实就是把函数作为这样的一个表达。在表达式函数中,又可以分为两种写法:
|
||||||
|
|
||||||
|
第一种是把函数当一个字面量的值来写,它的好处是可以自我引用。
|
||||||
|
|
||||||
|
var a = function() {} // 表达式
|
||||||
|
|
||||||
|
|
||||||
|
第二种是在ES6中,函数表达也可以通过箭头函数的方式来写。它的好处是可以用于this、参数和super相关的绑定。
|
||||||
|
|
||||||
|
var a = () => {}; // 表达式-箭头函数
|
||||||
|
|
||||||
|
|
||||||
|
延伸:类型之间如何转换?
|
||||||
|
|
||||||
|
另外,在JavaScript中,我们还会经常遇到的一个问题是:如何将一个值从一种类型转换到另外一种类型呢?
|
||||||
|
|
||||||
|
其实这里可以用到强制多态coercion。强制多态一般分为两种方式:一种是通过显性explicit的;另外一种是通过隐性implicit的。比如说,我们可以通过显式explicit,将一个字符串转化成为一个数字。
|
||||||
|
|
||||||
|
var a = 42;
|
||||||
|
var b = a + ""; // implicit coercion
|
||||||
|
var c = String(a); // explicit coercion
|
||||||
|
|
||||||
|
|
||||||
|
以上面这段代码为例,当a = 42时,它是一个数字,b通过隐性的强制多态,把它变成了一个字符串。c则通过显性的强制多态,把它变成了一个字符串。在ECMAScript 的官方文档中,有相关的运行时的判断条件,实际浏览器会通过类似以下的算法来判断对应的处理。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过这一讲,希望你对JavaScript的数据类型有了更系统的了解,也对不同数据类型的相关问题有了更好的解决方式,从而扬长避短。
|
||||||
|
|
||||||
|
但除了解决执行层面的问题,也更希望你能发现JavaScript之美和不足之处。如果你之前看过《黄金大镖客》,可能知道它的英文名字叫_The Good, the bad, the ugly_。在JavaScript里,通过对数据类型的认识,我们同样可以看到它的美和不足之处,这同时也才是它的精彩之处。
|
||||||
|
|
||||||
|
它的美在于简单、灵活。我们可以看到数字、字符串虽然简单,但又可以灵活地使用内置的包装对象,来返回属性和使用强大的调用功能;对象、函数、数组虽然是复杂的数据类型,但又都可以通过字面量来表示。
|
||||||
|
|
||||||
|
同时,JS也有着不足之处,特别是原始数据的一些缺陷和bug,但正是它的灵活和没有那么多条条框框,当初才吸引了很多开发者。除了语言本身的进化外,工程师们通过各种方式克服了这些缺陷。
|
||||||
|
|
||||||
|
最后,我们再来丰富下开篇中的脑图,一起总结下今天所学到的内容。让我们在记忆宫殿中将这些关键的信息通过这张脑图,放到我们信息大厦里的“JS之法”这一层吧!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
课程中我们总结过有些类型的数据既可以用字面量也可以用constructor来创建,那你觉得哪种方式在不同场景下更适合呢?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案和见解,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
238
专栏/JavaScript进阶实战课/11通过JS引擎的堆栈了解闭包原理.md
Normal file
238
专栏/JavaScript进阶实战课/11通过JS引擎的堆栈了解闭包原理.md
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
11 通过JS引擎的堆栈了解闭包原理
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在前面讲到编程模式的时候,我们就提到过闭包。
|
||||||
|
|
||||||
|
如果说一个函数“出生”的地方是作用域,从出生到被回收的“一生”是它的生命周期,那么闭包则可以突破这种空间和时间上的限制,那它是怎么做到这种突破的呢?
|
||||||
|
|
||||||
|
这节课,我们就从JavaScript编译原理以及其中的栈和堆的数据结构开始,来看看闭包的原理。
|
||||||
|
|
||||||
|
静态和动态作用域
|
||||||
|
|
||||||
|
我们先从作用域的空间概念来说。作用域可以分为静态作用域(static scope)和动态作用域(dynamic scope)。
|
||||||
|
|
||||||
|
静态作用域,取决于变量和函数在何处声明,这里你可以想象成它的“出生地”,并在它执行之前就已经确定了。所以静态作用域又被称为词法作用域(lexical scope),因为函数的“出生地”是在词法分析时“登记”的。
|
||||||
|
|
||||||
|
动态作用域则相反。动态作用域下,函数的作用域是在函数调用的时候才决定的。所以取决于在何处调用,这里你可以想象成它的“居住地”,这个是可以后天修改的。
|
||||||
|
|
||||||
|
我们所写的JavaScript代码,通常是通过前端浏览器编译后运行的,这个过程是先编译、后执行。所以JavaScript代码的作用域是在编译过程中通过分析它在何处声明来确定的,属于静态(词法)作用域。
|
||||||
|
|
||||||
|
下面我们来看看函数的作用域在代码编译的阶段是如何定义的,以及它在执行阶段的生命周期。
|
||||||
|
|
||||||
|
作用域:代码编译
|
||||||
|
|
||||||
|
我们先从作用域说起。下面以V8为例,我们先来看一段代码从编译到执行的过程总览。当我们打开一个页面,执行一段代码时,会经历从环境初始化到代码分析,从编译到执行的过程。具体你可以参考下方的图示:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
我们在第10讲里面介绍过JavaScript的数据类型。这里我们重点关注上图红色虚线框部分所示的存放和处理数据的栈空间和堆空间。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
栈是线性连续的数据结构的存储空间,里面主要存有JavaScript原始数据类型以及对象等复杂数据类型的地址。除此之外还有函数的执行状态和this值。堆是树形非连续的数据结构的存储空间,里面存储了对象、数组、函数等复杂数据类型,还有系统内置的window和document对象。
|
||||||
|
|
||||||
|
说完存储,下面通过一段代码,我们再来看下从词法到语法分析的过程。
|
||||||
|
|
||||||
|
var base = 0;
|
||||||
|
var scope = "global";
|
||||||
|
function addOne () {
|
||||||
|
var base = 1;
|
||||||
|
return base +1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayVal () {
|
||||||
|
var base = 2;
|
||||||
|
var scope = "local"
|
||||||
|
increment = addOne();
|
||||||
|
return base + increment;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
当我们输入上面这段代码后,代码像字符串一样被拆分成段,这个是叫做分词或词法分析(tokenizing/lexing)的过程。在这个过程中,比如var base = 0会被分为var变量、base、赋值表达、数字常量0。 词法作用域指的就是拆分成词法标记时这段代码所在的作用域。如下图红色虚线框部分所示:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在词法拆分之后,在下一步的解析(parsing)动作中,上面一段段的代码会被转换成一个抽象语法树(AST, Abstract Syntax Tree),这就到了语法分析。在这个语法树的顶端,我们可以看到一个父节点,它就是var这个变量的声明。在这个父节点的下面,有两个子节点:一个子节点是标识符count;另外一个子节点就是等号的赋值表达。在等号赋值表达的节点下面,还有一个子节点就是数字表面量0。如下图红色虚线框部分所示:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
根据流程图中的红色虚线框部分所示,在词法分析后,JavaScript引擎会在做语法分析的同时,更新全局作用域和创建局部作用域。在这个代码例子中,全局作用域里增加了base和scope变量,displayVal里有base、scope和increment变量,而addOne里有base变量。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在作用域创建后,上面的代码就会变为中间代码,V8 会混合使用编译器和解释器技术的双轮驱动设计实时编译(JIT Just in Time),这个双轮的一个轮子是直接执行,另一个发现热点代码会优化成机器码再执行,这样做的目的是为了性能的权衡和提升。这个我们在这一讲不需要深入学习,我们只需要知道在这之后就是编译的结束,我们的代码接下来要到执行过程了。
|
||||||
|
|
||||||
|
是不是有点晕,没关系,我们抽象总结一下。这里我们从空间角度了解到,函数在创建伊始是存放在堆空间中的,并且通过栈空间中的地址来查找。我们通过编译的过程,了解了作用域在代码未执行的解析阶段就完成了。
|
||||||
|
|
||||||
|
生命周期:代码执行
|
||||||
|
|
||||||
|
如果说作用域是从“空间”维度思考问题,那么生命周期就是从“时间”维度来看问题。接下来,咱们就来看看在代码执行的阶段,一个函数从调用到结束的过程,也就是它的生命周期。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
函数的生命周期
|
||||||
|
|
||||||
|
上面我们提到过堆和栈的概念。在JavaScript执行的时候,全局执行上下文会在一个类似栈的数据结构里面,根据函数调用链依次执行,所以又称为调用栈。下面我们看看根据上面的代码,按步骤会生成的栈。
|
||||||
|
|
||||||
|
一开始,base、scope、addOne、displayVal 都会被记录在变量环境。可执行的代码包含了base和scope的赋值,还有displayVal()函数的调用。当赋值结束就会执行displayVal函数。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在执行displayVal函数的时候,displayVal函数相关的全局上下文就会被压入栈内,因为base和scope都有函数内声明,所以它们在函数内也会有变量提升到increment的上面。作为可执行代码,完成base和scope的赋值。下面执行addOne函数的调用。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
再后面,需要继续将addOne压入栈内,base变量再次赋值,然后执行返回base+1的结果。在此以后,函数addOne的上下文会从栈里弹出,作为值返回到displayVal函数。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在最后的运行步骤里,displayVal 的increment会被赋值为2,之后函数会返回2+2的值,为4。之后displayVal的函数执行上下文也会被弹出,栈中将只剩下全局的执行上下文。addOne和displayVal这两个函数的生命周期就随着执行的结束而结束了,并且会在之后的垃圾回收过程中被回收。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
执行时变量查找
|
||||||
|
|
||||||
|
前面我们说过,JavaScript的作用域是在编译过程中的词法分析时决定的,下面我们就来看看从编译到执行的过程中,引擎是如何与作用域互动的。
|
||||||
|
|
||||||
|
还是以var base = 0为例,在下图的左边,我们可以看到当编译器遇到var base的时候,会问作用域这个base是否已经存在,如果是的话,会忽略这个声明;如果base不存在,则会让作用域创建一个新变量base,之后会让引擎处理base=2的赋值。
|
||||||
|
|
||||||
|
这个时候引擎又会回过头来问作用域,在当前执行的作用域当中有没有base这个变量,如果有的话,执行赋值,否则会继续寻找,一直到找到为止。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这里还有一个问题值得思考:上面例子中执行的最后一步,我们提到如果引擎在当前执行作用域找不到相关变量,会一直找或返回报错,那么这个“一直找”的顺序是什么呢?答案是它会从内往外地找。我们可以通过下面一个经典的嵌套的作用域例子来看。
|
||||||
|
|
||||||
|
在这个例子里,我们可以看到第一层是全局作用域,里面只有一个标识符为outer的函数声明。中间第二层是一个函数作用域,里面有a,也就是函数outer的形参;b是一个变量声明; inner是一个嵌套函数。然后最里面第三层的函数作用域里有a、b和c。当我们执行outer(1)的时候,引擎会从内到外,先从最里边的第三层找起,然后在第二层inner的作用域里找,如果找不到,就会在第一层outer的作用域里找,在这里可以找到a。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
IIFE:利用作用域封装
|
||||||
|
|
||||||
|
通过例子延伸,我们可知,作用域的层级可以分为块儿级、函数和全局这样的嵌套关系。块级作用域和函数级作用域都可以帮助我们对代码进行封装,控制代码的可见性。虽然常见的声明式函数可以帮助我们达到这个目的,但是它有两个问题:
|
||||||
|
|
||||||
|
|
||||||
|
第一个是如果我们以声明式函数为目的来做封装的话,它会间接地创建foo这个函数,会对全局作用域造成污染;
|
||||||
|
第二个问题是我们需要通过一个foo()来对它进行调用,解决这个问题的办法就是使用一个立刻调用的函数表达 (IIFE,immediately invoked function expression)。
|
||||||
|
|
||||||
|
|
||||||
|
在下面的例子中,我们可以看到,在使用这种表达方式的时候,我们可以使用第一组括号将函数封装起来,并且通过最后一组括号立刻调用这个函数。
|
||||||
|
|
||||||
|
var a = 2;
|
||||||
|
(function foo(){
|
||||||
|
var a = 3;
|
||||||
|
console.log( a ); // 3
|
||||||
|
})();
|
||||||
|
console.log( a ); // 2
|
||||||
|
|
||||||
|
|
||||||
|
闭包:突破作用域限制
|
||||||
|
|
||||||
|
前面我们系统地了解了函数的作用域和生命周期,提到了变量和函数在栈中的调用和执行后的销毁、垃圾回收,我将它归之“守正”。那么接下来,我们就看看如何突破这种限制,也可以谓之“出奇”。
|
||||||
|
|
||||||
|
在下面的代码中,我们运用了函数的3个特点:
|
||||||
|
|
||||||
|
|
||||||
|
在函数内部生成了一个局部的变量i;
|
||||||
|
嵌入了increment和getValue这两个函数方法;
|
||||||
|
把函数作为返回值用return来返回。
|
||||||
|
|
||||||
|
|
||||||
|
function createCounter(){
|
||||||
|
let i=0;
|
||||||
|
function increment(){
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValue(){
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return {increment,getValue}
|
||||||
|
}
|
||||||
|
|
||||||
|
const counter = createCounter();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
通过上述操作,我们可以看到,当我们执行下面的一段代码时,会发现即使在createCounter执行完成后,按道理相关变量 i 应该被销毁了,并完成相关的垃圾回收(garbage collection)。我们仍然可以访问它内部的变量 i,并可以继续调用increment和getValue方法。当我们尝试增加 i 的值的时候,会看到返回不断增加的结果。
|
||||||
|
|
||||||
|
counter.increment();
|
||||||
|
counter.getValue(); // 返回1
|
||||||
|
counter.increment();
|
||||||
|
counter.getValue(); // 返回2
|
||||||
|
|
||||||
|
|
||||||
|
它的原理是什么呢?这要回到我们较早前说到的解析或语法分析步骤。
|
||||||
|
|
||||||
|
在这个过程中,当JavaScript引擎解析函数的时候,用的是延迟解析,而不是及时解析。这样做的目的是减少解析时间和减少内存使用。所以在语法解析的时候,只会解析到createCounter函数这一层,而不会继续解析嵌入的increment和getValue函数。但是引擎同时又有一个预解析的功能,可以看到increment和getValue会引用一个外部的变量 i,所以会把这个变量从栈移到堆中,就用了更长的记忆来记录 i 的值。通过这种方式,闭包就做到了守正出奇,以此突破了作用域和生命周期的限制。
|
||||||
|
|
||||||
|
但是有一点要注意的是,考虑到性能、内存和执行速度,当使用闭包的时候,我们就要注意尽量使用本地而不要用全局变量。
|
||||||
|
|
||||||
|
延伸:提升问题和解决方法
|
||||||
|
|
||||||
|
变量和函数提升
|
||||||
|
|
||||||
|
下面我们再延伸看一下变量和函数声明的提升。我们先来看一个例子,我们把上面的var base = 2 这段代码拆开,可以看到第1行base=2是一个赋值的表达,第2行var base是一个变量声明。常识会告诉我们,这可能造成base返回的是undefined,因为base在还没声明的时候就被赋值了。可当你执行console.log(base) 时看到返回的结果是2。
|
||||||
|
|
||||||
|
base = 2;
|
||||||
|
var base;
|
||||||
|
console.log(base); // 2
|
||||||
|
|
||||||
|
|
||||||
|
为什么会这样呢?因为在代码的编译执行过程中,var base这个声明会先被提升,base=2这个赋值的动作才被执行。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在下面的例子,我们再来看看如果我们先通过console.log试图获取base的值,然后再写一句var base = 3 这样的变量声明和赋值,结果是不是按照变量提升原则应该返回3呢?答案是undefined!
|
||||||
|
|
||||||
|
console.log(base); // undefined
|
||||||
|
var base = 3;
|
||||||
|
|
||||||
|
|
||||||
|
在这个编译执行过程中,var base=3 这个声明和赋值会被拆成两个部分,一个是声明 var base,一个是赋值 base=3。变量提升,提升的只是变量base的声明;变量base的赋值是不会被提升的,而仍然是提升后执行的。下面显示的是它的词法拆分和提升后的顺序。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
和变量一样,函数声明也会被提升到顶部,而且如果函数和变量的提升同时发生,函数会被提到变量的前面。另外一点值得注意的是,如我们在前一讲所说,函数提升的只是声明式函数,而表达式函数则和变量赋值一样,不会被提升。
|
||||||
|
|
||||||
|
ES6块级作用域
|
||||||
|
|
||||||
|
不过关于变量和函数的提升特点,其实还存在着一定的问题,就是会造成变量的覆盖和污染。从ES6开始,除了全局作用域和函数作用域外,还有一个就是块级作用域被引进了JavaScript,所以在变量声明中,除了var以外,加入了let和const,这两个变量和常量就是块级作用域的变量,它们不会被提升。
|
||||||
|
|
||||||
|
我们可以尝试下,当我们输入 console.log(base),然后再用let声明 base=0 会发现报错。
|
||||||
|
|
||||||
|
{
|
||||||
|
console.log(base); // ReferenceError!
|
||||||
|
let base = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
同样地,在下面的例子里,我们也可以看到在if esle大括号里的 count 不会污染到全局作用域。
|
||||||
|
|
||||||
|
var base = 1;
|
||||||
|
if (base) {
|
||||||
|
let count = base * 2;
|
||||||
|
console.log( count );
|
||||||
|
}
|
||||||
|
console.log( count ); // ReferenceError
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这节课我们用了较多的篇幅讲了“守正”,也就是一般情况下一个函数的作用域和生命周期;较少的篇幅讲了“出奇”,也就是闭包突破限制的原理。因为只有当我们对函数的实际编译和执行过程有所了解,站在一个函数的角度,和它一起走过一遍生命旅程,从它是怎么创建的,一步步到它的词法和语法分析,编译优化到执行,调用到销毁到回收,我们才能更清楚地了解如何利用规则,或者更近一步突破规则的限制。
|
||||||
|
|
||||||
|
同时我们也看到了JavaScript本身的变量和函数提升具有一定的反直觉性,虽然我们不能说这是一个bug,但在一些开发者看来是一种缺陷。所以在后面的ES6开始,引进了块级作用域。希望你通过对原理的了解,能够更加清楚它们的使用方法。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
在讲到函数式编程时,我们说到了闭包可以作为和对象相比的数据存储结构,在讲到面向对象编程模式时,我们也说到了它可以用来创建对象的私有属性。那么除了这些例子外,你还能举例说明它的其它作用吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的积累,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
219
专栏/JavaScript进阶实战课/12JS语义分析该用迭代还是递归?.md
Normal file
219
专栏/JavaScript进阶实战课/12JS语义分析该用迭代还是递归?.md
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
12 JS语义分析该用迭代还是递归?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在前面两讲中,我们学习了JavaScript语言的数据类型,通过堆栈的数据结构了解了闭包的原理。这一讲,我们来聊聊算法。前面我们在讲到编程模式时提到,如果说函数式编程是输入、计算和输出,那中间的计算部分就可能用到算法了。而迭代和递归可以说是非常基础的算法。
|
||||||
|
|
||||||
|
迭代相对比较好理解,只要用过for loop的话,你对它就不会太陌生,而递归比较难理解。但和闭包一样,一旦你掌握了它的运用技巧,就会体会到它的强大之处。
|
||||||
|
|
||||||
|
我们在讲到函数式编程的时候,也说过一个很重要的思想就是“副作用”,而递归就自带很多副作用,相应地也出现了很多的解决方案。今天,我们就来看看它都有哪些副作用,以及解决这些副作用我们可以用哪些方法。
|
||||||
|
|
||||||
|
那在这之前,我们先来看看迭代和递归分别是什么。
|
||||||
|
|
||||||
|
迭代和递归的区别
|
||||||
|
|
||||||
|
首先我们得搞清楚迭代和递归有什么区别?
|
||||||
|
|
||||||
|
先说迭代,举个例子。做过软件工程的同学都经历过迭代吧,如果是一个敏捷的项目,它的过程就是一个小步快跑的过程,功能不是一下子都做出来的,而是根据优先级,先做优先级高的,再做优先级低的。这就是一个通过循环往复不断完善的过程。
|
||||||
|
|
||||||
|
而递归呢,就好比我们寄快递。寄出去的过程是递,收到签收的回执就是归,但是这个过程中间可不只一个人,而是在寄出去的过程,是一个人先运到中转站,再有人运到联络处,最后才到我们手中。哪怕回去的回执是电子的,但在网络世界里,信息的传递也是需要经过每个节点。这样的一去一回就是递归。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
而同样的一个问题,我们可能既可以用迭代的方式来做,也可以用递归的方式来做。
|
||||||
|
|
||||||
|
比如我们要计算阶乘。7的阶乘是“7!”,等于 7 * 6 * 5 * 4 * 3 * 2 * 1,结果是5040。如果用一个迭代函数来计算,大概是如下的方式。在每一次的迭代循环过程中,我们都用之前的乘积乘以下一个要迭代的 n 递减的数字。
|
||||||
|
|
||||||
|
function factorialIterative(number) {
|
||||||
|
if (number < 0) return undefined;
|
||||||
|
let total = 1;
|
||||||
|
for (let n = number; n > 1; n--) {
|
||||||
|
total = total * n;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
console.log(factorialIterative(7)); // 5040
|
||||||
|
|
||||||
|
|
||||||
|
如果我们用递归的方式来解决会是什么样子呢?
|
||||||
|
|
||||||
|
在递归里面,通常有两个基本元素,一个是基本条件(base case),也叫做终止条件(stop point);另外一个是递归本身。
|
||||||
|
|
||||||
|
现在我们可以看到,如果我们把上面的例子转变成递归的形式,即如下。在递归中调用的函数一般就是函数本身。
|
||||||
|
|
||||||
|
function factorialRecursive(n) {
|
||||||
|
// 基本条件
|
||||||
|
if (n === 1 || n === 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// 递归调用
|
||||||
|
return n * factorialRecursive(n - 1);
|
||||||
|
}
|
||||||
|
console.log(factorialRecursive(7)); // 5040
|
||||||
|
|
||||||
|
|
||||||
|
上面这段代码在执行中如下。我们可以看到前面7步,都是递的过程。在碰到基本条件后,开始了归的过程。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
递归中用到的分治
|
||||||
|
|
||||||
|
下面我们再来用经典的斐波那契(Fibonacci Sequence)数列对比下迭代和递归。斐波那契数列的特点就是这个数列从第3项开始,每一项都等于前两项之和。按照这样的规则,到第10项的时候,相加的值应该是55。
|
||||||
|
|
||||||
|
1,1,2,3,5,8,13,21,34,55
|
||||||
|
|
||||||
|
|
||||||
|
如果用迭代的方式来写计算第n项的斐波那契数的函数的话,大致如下:
|
||||||
|
|
||||||
|
function fibIterative(n) {
|
||||||
|
if (n < 1) return 0;
|
||||||
|
if (n <= 2) return 1;
|
||||||
|
let fibNMinus2 = 0;
|
||||||
|
let fibNMinus1 = 1;
|
||||||
|
let fibN = n;
|
||||||
|
// n >= 2
|
||||||
|
for (let i = 2; i <= n; i++) {
|
||||||
|
// f(n-1) + f(n-2)
|
||||||
|
fibN = fibNMinus1 + fibNMinus2;
|
||||||
|
fibNMinus2 = fibNMinus1;
|
||||||
|
fibNMinus1 = fibN;
|
||||||
|
}
|
||||||
|
return fibN;
|
||||||
|
}
|
||||||
|
console.log(fibIterative(10)); // 55
|
||||||
|
|
||||||
|
|
||||||
|
如果我们使用的是递归的话,从代码上看就简洁多了。在这里我们也使用了另外一个核心算法思想,就是分治(divide and conquer)。分治的意思就是分而治之。因为前面我们说了,斐波那契数列的特点就是这个数列从第3项开始,每一项都等于前两项之和。所以在这里,我们就先分别调用fibRecursive(n - 1)和fibRecursive(n - 2)这两个递归函数来分别计算前两项,之后我们再把它们相加,得到最终的结果。
|
||||||
|
|
||||||
|
function fibRecursive(n){
|
||||||
|
// 基本条件
|
||||||
|
if (n < 1) return 0;
|
||||||
|
// 基本条件
|
||||||
|
if (n <= 2) return 1;
|
||||||
|
// 递归+分治
|
||||||
|
return fibRecursive(n - 1) + fibRecursive(n - 2);
|
||||||
|
}
|
||||||
|
console.log(fibRecursive(10)); // 55
|
||||||
|
|
||||||
|
|
||||||
|
但是这里有一个问题,当我们在计算fibRecursive(5)的时候,fibRecursive(3)被计算了两次。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那么有没有什么办法,能够记住之前计算的结果,来避免这种重复的计算呢?
|
||||||
|
|
||||||
|
递归中的记忆函数
|
||||||
|
|
||||||
|
对了,我们可以利用上节课学到的作用域和闭包,在这里它又一次展示了它的强大。我们可以用闭包把递归函数中加入记忆(memoization)。在这里,fibonacci是一个递归,但是我们让它调用了一个外部的memo参数,这样一来memo就带有了“记忆”。我们可以用它来存储上一次计算的值,就可以避免重复计算了。所以记忆函数经常和递归结合起来使用。这里解决的重复计算问题,在算法中也被称为重叠子问题,而记忆函数就是一个备忘录。
|
||||||
|
|
||||||
|
function fibMemo(n, memo = [0, 1, 1]) {
|
||||||
|
if (memo[n]) {
|
||||||
|
return memo[n];
|
||||||
|
}
|
||||||
|
// 递归+分治+闭包
|
||||||
|
memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
|
||||||
|
return memo[n];
|
||||||
|
}
|
||||||
|
console.log(fibMemo(10)); // 55
|
||||||
|
|
||||||
|
|
||||||
|
递归中的尾递归
|
||||||
|
|
||||||
|
在上面的例子里,我们可以看到它的时间复杂度是\(O(2^{n})\)。那有没有办法能够把时间和空间复杂度都降低到\(O(n)\)呢?这里面我们可以看看尾递归。尾递归的意思就是在函数的尾部执行递归的调用。通过用尾递归的方式,最多递归n次,因为在每次递归的过程中都会n-1。
|
||||||
|
|
||||||
|
function fibTailRecursive(n, lastlast, last){
|
||||||
|
if (n == 0) {
|
||||||
|
return lastlast;
|
||||||
|
}
|
||||||
|
if (n == 1) {
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
return fibTailRecursive(n-1, last, lastlast + last);
|
||||||
|
}
|
||||||
|
console.log(fibTailRecursive(10, 0, 1)); // 55
|
||||||
|
|
||||||
|
|
||||||
|
递归中的内存管理
|
||||||
|
|
||||||
|
在上一讲中,我们在了解闭包的原理的过程中也了解了函数调用栈的概念。在国外,有一个很有名的技术问答网站叫做stack overflow,翻译成中文就是栈溢出。在我们用递归的时候,如果没有很好的控制,就会遇到这个性能问题。所以下面,我们再来看看递归中的内存管理。
|
||||||
|
|
||||||
|
在前面的例子中,我们看到在用递归来代替迭代的方案中,虽然它的写法比迭代要简便,但付出的是性能上的代价。因为这是一个函数不断自己调用自己的过程,会占用大量栈的空间,所以除了时间复杂度,它会有较高的空间复杂度需要考虑。而且稍有不慎,当它不能停止被调用的时候,可能会引起栈溢出。
|
||||||
|
|
||||||
|
比如在前面乘阶的例子中,我们可以在调用栈(call stack)中看到函数每次被调用的过程。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
JavaScript从ES6版本的标准开始,定义了尾调用优化。里面提到,如果一个函数是一个函数里的最后一个动作,它会被当做跳转而不是子程序来处理。也就是说这个代码会不停地被重复,所以这也是为什么要有一个基本条件的重要性。在实际操作中,绝大多数浏览器都会自己定义一个防止栈溢出的限制,比如Chrome在下面的一个无限循环的例子中调用了13952次之后,就出现了一个超出最大栈范围的错误消息,并且停止了递归。
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
function recursiveFn() {
|
||||||
|
i++;
|
||||||
|
recursiveFn();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
recursiveFn();
|
||||||
|
} catch (ex) {
|
||||||
|
console.log('i = ' + i + ' error: ' + ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
延伸:递归复杂度计算
|
||||||
|
|
||||||
|
在迭代中,Big-O的分析相对简单,因为循环可以清楚地定义什么时候增加、减少或者停止。但是在分析递归的时候,我们就需要分析两个部分了,一个是基础条件,一个是递归。所以在做递归的复杂度计算,通常会用到主定理(master theorem)。我们先来看看这个定理的组成部分。
|
||||||
|
|
||||||
|
在这里面,n是问题规模的大小,a是子问题个数,n/b是每个子问题的大小,\(O(n^{c})\)是将原问题分解和将子问题的解合并的时间。
|
||||||
|
\[T(n) = aT(n/b)+O(n^{c})\]基于\(c\)和\(log\_{b}(a)\)的对比,会有三种结果。\(log\_{b}(a)\)代表了\(aT(n/b)\),即解决当前层问题所需要的时间复杂度;\(c\)代表了\(O(n^{c})\),即将原问题分解和将子问题的解合并的时间。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
当然我们说并不是所有递归问题都符合主定理公式的形式,那么遇到这种问题该怎么办呢?在这种情况下,我们也可以尝试使用递推公式和递归树。
|
||||||
|
|
||||||
|
下面,我们先来看看使用递推公式的方式。递推公式可以帮助我们在写递归的时候,设计递归函数。同时,它还有另外一个作用就是计算复杂度。如果我们用递推公式来计算斐波那契的时间复杂度的话,要先提炼出它的递推公式及时间复杂度的推导过程。这里,你可以看到,每一次函数调用会产生两次额外的调用,计算呈指数级增加。
|
||||||
|
|
||||||
|
// 斐波那契递推公式
|
||||||
|
T (n) = T (n − 1) + T (n − 2)
|
||||||
|
|
||||||
|
// 时间复杂度的推导
|
||||||
|
T(n) = T(n − 1) + T(n − 2) + O(1);
|
||||||
|
T(n − 1) = T(n − 2) + T(n − 3) + O(1);
|
||||||
|
T(n − 2) = T(n − 3) + T(n − 4) + O(1);
|
||||||
|
|
||||||
|
// 每次指数级的增加
|
||||||
|
f(6) * <-- 一次
|
||||||
|
f(5) *
|
||||||
|
f(4) **
|
||||||
|
f(3) ****
|
||||||
|
f(2) ********
|
||||||
|
f(1) **************** <-- 16
|
||||||
|
f(0) ******************************** <-- 32
|
||||||
|
|
||||||
|
|
||||||
|
上面通过递推公式计算复杂度的方式还是很复杂的,那么还有没有更简单直观的计算方式呢?我们来看看递归树。递归树的方式,我们可以更直观地看出结果。这里当长路径为n的话,对应的耗时为\(2^{n}-1\),所以最高的时间复杂就是\(2^{n}\)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这节课我们学习了算法中最核心的两种方法,迭代和递归。就和我们讲JavaScript编程范式的时候,讲到函数中的闭包和对象中的this一样,你会发现,我们后面讲到的80%算法都离不开它的影子。请记住这个二八定律,只要把迭代和递归的概念吃透搞明白,对算法的学习,可以说是有着事半功倍的效果。
|
||||||
|
|
||||||
|
如果要对比迭代和递归的话,从整体的性能来说,迭代是优于递归的。而如果从代码的整洁性来看,递归看起来更简洁。而且递归和作用域、闭包结合起来形成的记忆函数和尾递归,都能从一定程度上减少其“副作用”。下面我们就结合这张图,总结下针对这些副作用的解决方法吧。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
所以在算法中我们应该用迭代还是递归呢?这里同样没有绝对,应用上,你可以根据它们的优劣势,结合实际情况来应用。我个人认为,我们写的代码主要还是给人读的,而不是最终的机器码。所以我的建议是以代码的“简洁可读性”为先,然后再针对机器无法替我们优化的“副作用”的部分所产生的问题做手动的优化。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
前面我们讲到了针对栈溢出的尾调用优化,你知道尾递归调用优化是如何实现的吗?
|
||||||
|
|
||||||
|
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
105
专栏/JavaScript进阶实战课/13JS引擎如何实现数组的稳定排序?.md
Normal file
105
专栏/JavaScript进阶实战课/13JS引擎如何实现数组的稳定排序?.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
13 JS引擎如何实现数组的稳定排序?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
我们经常将数据结构和算法这两个词连起来说,可是你有没有想过,这两者是什么关系?
|
||||||
|
|
||||||
|
可以说数据结构是服务于算法的。这么说比较概括。下面我们就找一个切入点来讲解两者的关系,我们先从排序算法说起。提到排序,我们就要说到常用的数据结构数组,比如在JS中我们一般都用过它的排序方法,可是你知不知道排序方法背后用的是哪种算法呢?这一讲,就通过数组了解排序算法,再来看JS 引擎是如何实现数组的排序的。
|
||||||
|
|
||||||
|
在数据结构中,我们大体可以分为两类。第一类是线性表,第二类是非线性表。这里的线性和我们生活中理解的线性没有本质上的区别,意思就是“按顺序的”。数组可以说是一种连续存储的线性表,并且可以算是一种比较基础的线性数据结构了。通过数组,我们可以实现比如之前说过的栈、队列等线性结构。你也可以用它来创建非常经典的非线性结构,比如堆和图。
|
||||||
|
|
||||||
|
除了数组以外,另外一种既基础又经典的数据结构就是对象了。对象的一个特点是含有属性,很多时候可以用来作为节点(node),通过节点相连的方式,就可以创建链式结构,比如链表;对象的另外一个特点是它支持键值对(key-value pair),通过这个特点可以用来实现散列表和字典。
|
||||||
|
|
||||||
|
回到今天的话题,我们来重点说说数组。
|
||||||
|
|
||||||
|
数组的优劣势是什么
|
||||||
|
|
||||||
|
数组作为一种线性数据结构最大的特点是连续性,带来的优势是可随机访问性,但是它的劣势是高效的插入和删除。几乎所有的语言都把数组当做一种内置的数据类型提供,在这一点上,JavaScript也不例外。作为JavaScript数据类型之一,我们在前面介绍过数组。但是作为数据结构,我们可以从另一个角度来理解它。
|
||||||
|
|
||||||
|
我们前面说,数组的劣势是插入和删除,这是什么意思呢?我们假设在排队的时候,有人插队,那么整个组的人都会往后移动。这个时候,时间复杂度为\(O(n)\)。
|
||||||
|
|
||||||
|
那么使用数组的时候如何尽量克服这个劣势问题呢?和排队一样,方法就是按规矩让这个人排到队尾。在数组中,如果把数据元素插到队尾的话,那么整个时间复杂度仅仅为\(O(1)\)。所以通常,如果我们不在乎增加一个新的数据到哪儿,那么最好就是把它加到队尾。在JavaScript中,如何将一条信息加到数组的队尾呢?我们知道数组也是一种对象,那么它也有着一些自带的属性和方法用来对数组进行操作。在JavaScript当中,实现插入最简单的方式就是在数组的长度基础上算出下一个空位,赋值为我们想加入的数据元素。
|
||||||
|
|
||||||
|
var week = [1,2,3,4,5,6];
|
||||||
|
week[week.length] = 7;
|
||||||
|
console.log(week) // 返回 [1,2,3,4,5,6,7]
|
||||||
|
|
||||||
|
|
||||||
|
在这里,你可以看到它和C或者Java相同和不同的影子。和C或者Java相同的是,我们的直觉会告诉我们开始数组长度是6,那在第6个位置上赋值,怎么会是空位,这样不会覆盖到第6个数字吗?不会。这是因为数组通常是从0而不是1开始计算的。和C或者Java不同的是,在JavaScript里,数组的长度是可以动态扩容的,或者用我们之前讲的函数式思想来看,内容也是可变的,这点从上面的例子就可以看出。而在C和Java里,数组的长度是一开始就决定的,如果这时候增加一个新的数据元素,就需要创建一个新的数组。
|
||||||
|
|
||||||
|
除了上面的方法,还有另外一种方法可以快速地增加一个值到数组的尾部,这种方式就是使用JavaScript中数组自带的push方法。从这里,我们大概可以看出队列和栈的影子。当我们用上面排队举例子时,其实就有队列的概念。下面的push在英文里,其实就是“压栈”的意思。
|
||||||
|
|
||||||
|
var week = [1,2,3,4,5,6];
|
||||||
|
week.push(7);
|
||||||
|
console.log(week) // 返回 [1,2,3,4,5,6,7]
|
||||||
|
|
||||||
|
|
||||||
|
JS如何实现数组的排序
|
||||||
|
|
||||||
|
说完了数组的优劣势,我们来看看它的排序实现。数组是如何实现排序的呢,通过下面的例子,我们可以来看看几种排序方式。其中前2种冒泡和选择排序是偏理论型的排序方式,后面3种,插入、快排和归并属于应用型的算法。Chrome用的V8引擎就是快排和插入排序的结合,火狐用的SpiderMonkey则是基于归并来实现排序的。为什么浏览器会使用这几种排序方式呢?我们可以通过它们的原理和特点来了解。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
理论类排序法
|
||||||
|
|
||||||
|
首先,我们先来看看理论类的冒泡、选择这两种排序算法。这两种方式用的都是一种比较和交换的方法(compare and swap)。我们上面说过,对于数组来说,如果我们在非队尾的位置插入的话,就容易造成队列中插入的元素以后的一系列元素的移动。但是如果使用交换的方式,就巧妙地避免了这个问题。
|
||||||
|
|
||||||
|
冒泡排序(bubble sort)的核心思想就是通过比较两个相邻的数据元素,如果前面的大于后面的,就交换它们的位置。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
选择排序(selection sort)用的是一种原地比较的算法(in-place compare),它的核心思想就是找到最小的数据元素,把它放到第一位,然后再从剩下的数组中找到最小的数据元素,然后放到第二位,以此类推。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
之所以说这两类排序算法更多是理论层面,是因为它们的时间复杂度都是\(O(n^{2})\)。下面我们来看看有没有时间复杂度更低的方式来处理数组的排序。
|
||||||
|
|
||||||
|
常用类排序法
|
||||||
|
|
||||||
|
下面我们再来看看一些非理论类,实际使用中常用的排序算法。
|
||||||
|
|
||||||
|
插入排序(insertion sort)和之前的冒泡和选择排序法不同,它不是用比较和交换,而是通过位移来做插入。它假设数组中第1个元素已经排序了。从第2个和第1个比较,如果小于第1个值,就插入到前面,如果大于,就原地不动;第3个值同理和前面的2个比较,小于就左移插入,大于就不动;后面的操作都以此类推。虽然插入排序虽然从复杂度上来看,和冒泡及选择算法类似,都是\(O(n^{2})\),但是实际上,对于一些较短的数组来说,它的性能是明显更好的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
归并排序(merge sort)可以算是比较常见的应用型排序算法了,它的复杂度是\(O(n log n)\)。下面,我们就来看看它的实现。归并算法用到的是我们前面介绍过的递归和分治的算法思想。这种方法的核心就是先把一个大的数组拆分成只包含一个元素的不可再分的数组,然后两两比较,最后再合并成一个数组。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
下面,我们再来看看快排算法(quick sort),和归并类似,快排也用到了递归和分治,同样,它的时间复杂度是\(O(n log n)\)。说它也用了分治,是因为快排也会对数组“分开来”分析;但是和归并不同的是,它不会把元素真的“拆出来”再组合。那么它是怎么实现排序的呢?
|
||||||
|
|
||||||
|
在快排中,用到的是区分点(pivot)和指针(pointer)。在数组中,我们会在最前和最后一个元素的位置各加一个指针,然后在中间的位置加一个区分点。我们会不断移动左指针,直到我们找到一个元素大于区分点;同时,我们也会不断移动右指针,直到我们找到一个小于区分点的数,然后将它和左指针指向的数据交换。这里类似我们在冒泡排序中提到的比较和交换的算法。这个过程可以使得所有区分点左边的值都小于右边的数,这个过程叫做分区(partition)。这个方式会按照递归的方式不断重复,直到最后整个数组排序完成。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
关于快排有一点需要注意,就是分区的实现。关于分区实现中的区分点,我们需要注意的是为了避免分区后两个区域的数据量相差很大,我们可以默认在两个指针之间选一个平均值,但更好的办法是通过随机的方式来选择,虽然这样不能保证最好的结果,但从概率角度,也不会太差。
|
||||||
|
|
||||||
|
排序的稳定性问题
|
||||||
|
|
||||||
|
前面我们说了那么多,那么回到我们之前的问题,为什么V8用的是插入加快排的方式,SpiderMonkey用的是归并的方式来做排序呢?这就要从排序的稳定性说起了。在ECAMScript的官方文档中,虽然并不指定JS引擎对sort的具体实现方式,但是有一条准则,就是要确保数组的排序必须是稳定排序(stable sort)。
|
||||||
|
|
||||||
|
从理论上讲,虽然归并和快排的复杂度都是相同的,但是前面我们忽略了两个重要因素,第一个是时间复杂度的平均和极端情况,第二个是空间复杂度的问题。快排在平均情况下的空间复杂度是\(O(n log n)\),但是在极端情况下是\(O(n^{2})\)。
|
||||||
|
|
||||||
|
那你会说,这样为什么不直接用归并排序,因为归并排序也不是没有问题,在使用归并排序的时候,虽然它是稳定的,但它不是原地排序法,这就造成了它的空间复杂度是\(O(n)\),必然要高于其它的算法类型。所以这就是为什么V8和SpiderMonkey各自选择不同算法的原因。对于V8而言,为了既解决稳定性问题,又解决复杂度问题,就使用了快排加插入的方式,在数据量比较小的情况下,使用插入;在数据量比较大的时候,则切换到快排。而对于SpiderMonkey来说,就通过归并来满足稳定性的需求。
|
||||||
|
|
||||||
|
延伸:线性类排序法
|
||||||
|
|
||||||
|
还有一类的排序法我们称之为线性排序法,为什么这么叫呢。因为他们使用的都是非比较类的排序方法,这里包括了计数、桶和基数排序,因为这几种排序算法都是用于特殊场景,我们就不在这里讲它们的具体实现了,但是我也会把源码放到GitHub上。如果你想做延伸了解,可以通过代码注释来了解。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
今天我们通过数组这种最简单的数据结构了解了并不简单的排序算法。你应该学到了数组这种数据结构的优劣性,同时我们也看到了V8和SpiderMoney使用的排序方式。这一讲最核心的并不是我们学到了这些逻辑和应用,更重要的是我们看到了全面分析的思维方式。比如我们看到的时间复杂度只是一个方面,它不能直接代替性能。当我们把它和稳定性及空间复杂度的思考维度相加的时候,才能对解决方案作出更全面和客观的判断。同时,这也告诉我们我们所写的程序基于不同的引擎和数据量可能会有不同的结果。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
今天留给你的思考题是,通过今天学到的知识,你能不能讲讲你知道的其它的JS引擎用的是什么算法?
|
||||||
|
|
||||||
|
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
100
专栏/JavaScript进阶实战课/14通过SparkPlug深入了解调用栈.md
Normal file
100
专栏/JavaScript进阶实战课/14通过SparkPlug深入了解调用栈.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
14 通过SparkPlug深入了解调用栈
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在第11讲的时候,我们通过函数中的闭包了解了栈和堆这两种数据结构。在12讲中,我们通过递归了解了函数在调用栈中的循环执行。那么今天,我们再通过V8的Sparkplug编译器来深入的了解下JavaScript引擎中的调用栈,其中栈帧(stack frame),栈指针(stack pointer)和帧指针(frame pointer)的概念。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这里,可能有不太了解V8的同学,我们来简单看一下Sparkplug的前世今生。最开始的时候啊,V8用的是一个相对比较快的Full-codegen编译器生成未优化的代码,然后通过Crankshaft这个及时(JIT)编译器对代码进行优化和反优化。但是随着更多人浏览网页的习惯从PC端转向移动端,性能问题变得更加重要了。而这个流水线既没能对ES6之前的,也没能对ES6之后版本的JS做到理想的优化。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
所以随之而来的,V8就引入了Ignition解释器和TurboFan的优化编译器以移动优先为目的的流水线。它最大的特点呢,就是通过Ignition最终生成字节码,之后再通过TurboFan生成优化后的代码,和做相关的反优化。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
可是这个时候,问题又来了,TurboFan和Crankshaft比起来,有时也有不足。所以为了解决这个问题,V8又创建了一个把Full-codegen,Crankshaft,Ignition和TurboFan整合起来的“全家桶”的流水线。这显然让问题显得更复杂了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
还有就是从2016年开始,V8团队也从理论往实际转变,包括自研的Octane测试引擎都被真实场景的测试所取代了。并且努力在优化编译器外寻求性能优化方向,包括解析器、流、对象模型、垃圾回收器、编译代码缓存等方面的优化。然而在这个过程中,他们开始发现在优化解释器时遇到了比如字节码解码或分派这些成本的限制。基于V8的双编译器的模型,是没办法实现更快的分层代码优化的。如果想要提速,就只能删除一些优化关卡,但这样会降低峰值性能。运行的初始阶段在还没有稳定的对象隐藏类反馈的情况下,也没有办法开始优化。基于上面的种种原因和问题,Sparkplug就诞生了。作为非优化编译器,它存在于Ingition解释器和TurboFan优化编译器之间。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
为什么多了一个编译器
|
||||||
|
|
||||||
|
既然Sparkplug的目的是快速编译,那么为了达到这个目的,它就用到了两个重要的方法。
|
||||||
|
|
||||||
|
首先,它编译的函数已经被编译为字节码了。字节码编译器已经完成了大部分复杂的工作,比如变量解析、判断括号是否是箭头函数、去掉语法糖和解构语句等等。Sparkplug的特点是从字节码而不是从JavaScript源代码编译,因此不必担心这些问题。这也是它和Full-codegen的区别。因为在Full-codegen的场景里,Crankshaft需要将源码重新解析到AST语法树来编译;并且为了反优化到Full-codegen,它需要重复Full-codegen编译来搞定栈帧。
|
||||||
|
|
||||||
|
第二个方法是Sparkplug不会像TurboFan那样生成任何中间码 (IR)。什么是IR呢?它是一种从抽象到具象的分层结构。在具象中可以对具体内容比如新的ES标准,或特定机器比如IBM、ARM或Intel做特殊的机器码生成。TurboFan用的是基于节点海思想的IR。TurboFan在接受到Ignition的指示后,会进行优化处理,并生成针对于平台的机器代码。相反,Sparkplug在字节码上的单次线性传递中直接编译为机器码,产出与该字节码执行相匹配的代码。所以事实上,整个Sparkplug编译器是一个for循环内的switch语句,分配到基于每个固定的字节码的机器码的生成函数上。
|
||||||
|
|
||||||
|
// Sparkplug 编译器的部分代码
|
||||||
|
for (; !iterator.done(); iterator.Advance()) {
|
||||||
|
VisitSingleBytecode();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
IR的缺失意味着除了非常有限的窥孔优化外,Sparkplug的优化机会有限。这就是说因为它没有独立于架构的中间阶段,所以必须将整个实现分别移植到支持的每个架构中。但是实际上呢,这些都不是问题,因为Sparkplug是一个简单快速的编译器,所以代码很容易移植;而且因为在工作流中还是有TurboFan的,所以不需要进行大量优化。并且我们看到TurboFan的反优化会回到SparkPlug,而不是Ignition。
|
||||||
|
|
||||||
|
栈指针和帧指针的使用
|
||||||
|
|
||||||
|
下面我们就来看看栈帧的原理,还有栈指针和帧指针的使用。在成熟的JavaScript虚机中添加新的编译器其实很难。因为除了Sparkplug自身的功能,它还必须支持例如调试器、遍历堆栈的CPU分析器、栈的异常跟踪、分层的集成,及热循环到优化代码的栈替换(OSR,on-stack replacement)等工作。
|
||||||
|
|
||||||
|
那Sparkplug则用了一个比较聪明的方法,简化了大多数问题,就是它维护了“与解释器兼容的栈帧”。我们知道调用栈是代码执行存储函数状态的方式,而每当我们调用一个新函数时,它都会为该函数的本地变量创建一个新的栈帧。 栈帧由帧指针(标记其开始)和栈指针(标记其结束)定义。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
当一个函数被调用时,返回地址也会被压入栈内的。返回地址在返回时由函数弹出,以便知道返回到哪里。当该函数创建一个新栈帧时,也会将旧的帧指针保存在栈中,并将新的帧指针设置为它自己栈帧的开头。因此,栈有一系列的帧指针 ,每个都标记指向前一个栈帧的开始。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
除了函数的本地变量和回调地址外,栈中还会有传参和储值。参数(包括接收者)在调用函数之前以相反的顺序压入栈内,帧指针前面的几个栈槽是当前正在被调用的函数、上下文,以及传递的参数数量。这是“标准” JS 框架布局:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
为了使我们在性能分析时,以最小的成本遍历栈,这种 JS 调用约定在优化和解释栈帧之间是共享的。
|
||||||
|
|
||||||
|
Ignition解释器会进一步让调用约定变得更加明确。Ignition是基于寄存器的解释器,和机器寄存器的不同在于它是一个虚拟寄存器。它的作用是存储解释器的当前状态,包括JavaScript函数局部变量(var/let/const 声明)和临时值。这些寄存器存储在解释器的栈帧中。除此以外,栈帧中还有一个指向正在执行的字节码数组的指针,以及当前字节码在该数组中的偏移量。
|
||||||
|
|
||||||
|
后来,V8团队对解释器栈帧做了一个小改动,就是Sparkplug在代码执行期间不再保留最新的字节码偏移量,改为了存储从Sparkplug代码地址范围到相应字节码偏移量的双向映射。因为Sparkplug代码是直接从字节码的线性遍历中发出的,所以这是一个相对简单的编码映射。每当栈帧访问并想知道Sparkplug栈帧的“字节码偏移量”时,都会在映射中查找当前正在执行的指令并返回相应的字节码偏移量。同样,每当它想从解释器到Sparkplug进行栈替换(OSR,on-stack replacement)时,都可以在映射中查找当前字节码偏移量,并跳转到相应的Sparkplug指令。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Sparkplug特意创建并维护与解释器相匹配的栈帧布局;每当解释器存储一个寄存器值时,Sparkplug也会存储一个。它这样做有几点好处,一是简化了Sparkplug的编译, Sparkplug可以只镜像解释器的行为,而不必保留从解释器寄存器到Sparkplug状态的映射。二是它还加快了编译速度,因为字节码编译器已经完成了寄存器分配的繁琐的工作。三是它与系统其余部分(如调试器、分析器)的集成是基本适配的。四是任何适用于解释器的栈替换(OSR,on-stack replacement)的逻辑都适用于Sparkplug;并且解释器和Sparkplug代码之间交换的栈帧转换成本几乎为零。
|
||||||
|
|
||||||
|
之前字节码偏移量空出来的位置,在栈帧上形成了一个未使用的插槽,这个栈槽被重新定义了目的,来缓存当前正在执行的函数的“反馈向量”;来存储在大多数操作中都需要被加载的对象结构的数据。因此Sparkplug栈帧最终是这个样子的:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Sparkplug实际的代码很少,基本工作就是内置模块调用和控制流。为什么会这样呢?因为JavaScript语义很复杂,即使是最简单的操作也需要大量代码。强制Sparkplug在每次编译时内联重新生成代码会明显增加编译时间,而且这样也会增加Sparkplug代码的内存消耗,并且V8必须为Sparkplug的一堆JavaScript功能重新实现代码生成,这也可能引起更多的错误和造成更大的安全暴露。因此,大多数Sparkplug代码是调用“内置模块”的,即嵌入在二进制文件中的小段的机器码,来完成实际的脏活累活儿。这些内置函数基本与解释器使用相同,或至少大部分共享。
|
||||||
|
|
||||||
|
这时你可能会产生一个疑问就是Sparkplug存在的意义,感觉它和解释器做几乎同样的工作。在许多方面,Sparkplug的确只是解释器执行的序列化,调用相同的内置功能并维护相同的栈帧。但尽管如此,它也是有价值的,因为它消除了或更严谨地说,预编译了那些无法消除的解释器成本,因为实际上,解释器影响了许多CPU优化。
|
||||||
|
|
||||||
|
例如操作符解码和下一个字节码调度。解释器从内存中动态读取静态操作符,导致CPU要么停止,要么推测值可能是什么;分派到下一个字节码需要成功的分支预测才能保持性能,即使推测和预测是正确的,仍然必须执行所有解码和分派代码,结果依然是用尽了各种缓冲区中的宝贵空间和缓存。尽管CPU用于机器码,但本身实际上就是一个解释器。从这个角度看,Sparkplug其实是一个从Ignition到CPU字节码的“转换器”,将函数从“模拟器”中转移到“本机”运行。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过今天对Sparkplug的讲解,我们在之前对栈这种数据结构的了解基础上,更多的了解了栈帧、栈针和帧指针的概念,同时也更多的了解了JS的编译流水线。感兴趣的同学也可以看看我在参考中提供的V8博客中的相关文章的链接,同时,这个网站也有很多的语言和编译原理的解释,从中我们也可以看到V8对性能极致优化的追求,也是很值得学习的资料。同时如果你对编译原理很感兴趣的话,也可以看看隔壁班宫文学老师的编译原理课。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
在前面我们说过,Sparkplug并不是完全没有优化,它也会做窥孔优化。你知道这个优化的原理吗?
|
||||||
|
|
||||||
|
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
参考
|
||||||
|
|
||||||
|
|
||||||
|
V8 博客:https://v8.dev/blog/sparkplug
|
||||||
|
隔壁班宫文学老师的课程:《编译原理之美》
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
146
专栏/JavaScript进阶实战课/15如何通过哈希查找JS对象内存地址?.md
Normal file
146
专栏/JavaScript进阶实战课/15如何通过哈希查找JS对象内存地址?.md
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
15 如何通过哈希查找JS对象内存地址?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
我们曾经讲过,在Javascript中,对象在调用栈中只做引用不做存储,实际的存储是在堆里面实现的。那么我们如何查找对象在堆里的实际存储地址呢?通过我们对字典的了解,这个问题就迎刃而解了。字典也被称作映射、符号表或关联数组,这么说可能比较抽象,所以我们先来说说字典的一种实现:散列表。
|
||||||
|
|
||||||
|
散列表:如何检查单词是否存在
|
||||||
|
|
||||||
|
如果你用过一些文档编辑的软件,应该很常用的一个功能就是拼写检查,这个检查是怎么做到的呢?从它的最底层逻辑来说,就是看一个单词存在与否。那么一个单词是否存在是如何判断的呢?这里就需要用到散列表。散列表的实现逻辑就是基于每个单词都生成一个唯一的哈希值,把这些值存放在一个数组中。当我们想查询一个词是否有效,就看这个词的哈希值在数组中是否存在即可。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
假设我们有上图中这样的一组城市的键值对组成的对象,我们可以看出,在哈希的过程中,一个城市的键名,通过一个哈希函数,生成一个对应的唯一的哈希值,这个值被放到数组中,形成一个哈希列表。下次,当我们想要访问其中数据的时候,就会通过对这个列表的遍历来查询相关的值。
|
||||||
|
|
||||||
|
这里我们可以看到,图中间位置的是哈希函数,我们需要一个哈希函数来生成哈希值,那么哈希值是怎么生成的呢?生成散列表中的哈希值有很多种方式,比如素数哈希、ASCII哈希,还有djb2等方式。
|
||||||
|
|
||||||
|
在哈希算法当中,最基础的就是素数(prime number)哈希。这里我们把一个素数作为模数(modulus number),来给你举一个例子,在这个例子里,我们把11这个素数作为了模数,用下面的一组键值对中的键除以模数,所获得的余数,放到一个数组中。就形成了一个散列表。这样可以获得一个统一的索引。
|
||||||
|
|
||||||
|
{key:7, value: "南昌"}
|
||||||
|
{key:24, value: "太原"}
|
||||||
|
{key:42, value: "郑州"}
|
||||||
|
Prime number: 11
|
||||||
|
7 % 11 = 7 // 余数为7
|
||||||
|
24 % 11 = 2 // 余数为2
|
||||||
|
42 % 11 = 9 // 余数为9
|
||||||
|
|
||||||
|
|
||||||
|
这个方式看似可以用来生成哈希值,但是也存在一个问题。在将余数放入数组的过程中,我们会发现,如果处理的数据数量足够多,那么就会出现冲突的情况,比如下图中标红的两个对象的键除以素数11的余数是相同的,7和51的余数都是7,这样就会造成冲突。一个完美的哈希表是不应该存在冲突的,可是这样完美的哈希表其实在现实中并不存在,所以我们只能尽量减少这种情况。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
为了尽量减少这种冲突,业界也在尝试其他办法,比如使用ASCII code和素数结合来生成哈希,但这种方式和上面的素数哈希一样,即使结合了ASCII,哈希值也不能完全避免碰撞的产生,只能减少冲突。
|
||||||
|
|
||||||
|
asciiHashCode(key) {
|
||||||
|
if (typeof key === 'number') {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
const tableKey = this.toStrFn(key);
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < tableKey.length; i++) {
|
||||||
|
hash += tableKey.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return hash % 37;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
除此之外,还有一种经典的djb2的算法,可以用来进一步减少这种问题的发生。它的做法是先用一个长质数5381作为哈希数,然后根据字符串长度循环,将哈希数乘以33,再加上字符的ASCII码迭代。结果和模数1013的余数结果就是最后的哈希值。
|
||||||
|
|
||||||
|
这里你可能会问,33和5381这两个数字是什么意思?这里乘以33呢,是因为更易于移位和加法计算。使用33可以复制累加器中的大多数输入位,然后将这些位分散开来。5的移位和32是互素的,这有助于雪崩。ASCII可以看做是2个4位字符类型选择器,比如说,数字的前四位都是0x3。所以2、4、8位都可能导致相似的位之间交互,而5位可以让一个字符中许多的4个低位与4个高位强烈交互。所以这就是选择33的原因。那么至于原则5381作为质数呢,则更多是一种习惯,也可以由其它大的质数代替。
|
||||||
|
|
||||||
|
djb2HashCode(key) {
|
||||||
|
const tableKey = this.toStrFn(key);
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < tableKey.length; i++) {
|
||||||
|
hash = (hash * 33) + tableKey.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return hash % 1013;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这几种方式只是给你一个概念,实际上哈希函数的实现方法还有很多,可能专门一本书都不一定能讲完,但是在这里,我们只是为了了解它的原理和概念。
|
||||||
|
|
||||||
|
通过哈希函数,我们基本可以让一个单词在哈希表中找到自己的存在了。那么解决了存在的问题后,单词就该问“我的意义是什么?”,这个时候,我们就需要字典的出场了。
|
||||||
|
|
||||||
|
字典:如何查找对象的内存地址
|
||||||
|
|
||||||
|
散列表可以只有值,没有键,可以说是数组延伸出来的索引。说完散列表,我们再来看看字典(dictionary)。顾名思义,这种数据结构和我们平时用的字典类似,它和索引的主要的作用都是快速地查询和搜索。但是我们查字典的时候,关心的不光是有没有这个词,更重要的是,我们要知道这个单词对应的意思。所以我们需要通过一组的键值对来表明它们的关系。
|
||||||
|
|
||||||
|
我们设想一个很初级的字典,就是每个字都有一个哈希作为键名,它的意思作为键值。这样一条条地放到每一页,最后形成一个字典。所以通常字典是有键值对的。所以我们前面说过,字典作为一种数据结构,又叫做映射(map)、符号表(symbol table)或者关联数组(associative array)。在JavaScript中,我们其实可以把对象看做是一种可以用来构建字典的一种散列表,因为对象里就包含key-value的属性。
|
||||||
|
|
||||||
|
回到我们开篇的问题,在前端,最常见的字典就是我们使用的对象引用了。我们用的浏览器和JS引擎会在调用栈中引用对象,那么对象在堆中的实际位置如何寻找呢?这里,对象在栈的引用和它在堆中的实际存储间的关联就是通过地址映射来实现的。这种映射关系就是通过字典来存储的。我们可以用浏览器打开任何一个页面,然后打开开发者工具,在工具里,我们进入内存的标签页,然后选择获取一个堆的快照,之后我们便可以看到对象旁边的@后面的一串数字,这串数字就是对象在内存的地址。所以这里的字典就是一部地址簿。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Map和Set:各解决什么问题
|
||||||
|
|
||||||
|
在ES6之前,JavaScript中只有数组和对象,并没有字典这种数据结构。在ES6之后才引进了Map和Set的概念。
|
||||||
|
|
||||||
|
JavaScript中的Map就是字典的结构,它里面包含的就是键值对。那你可能会问,它和对象有什么区别?我们说过对象就是一个可以用来实现字典的支持键值对的散列表。Map和对象最大的区别就是Map里的键可以是字符串以外的其它数据结构,比如对象也可以是一个键名。
|
||||||
|
|
||||||
|
JavaScript中的Set就是集合的结构,它里面包含值,没有键。这里你也可能会问,那这种结构和数组有什么区别?它的区别主要在于JS中的集合属于无序集合,并且里面不能有相同的元素。
|
||||||
|
|
||||||
|
JavaScript同时还提供了WeakMap或WeakSet,用它们主要有2个原因。第一,它们都是弱类型,代表没有键的强引用。所以JavaScript在垃圾回收时可以清理掉整条记录。第二个原因,也是它的特点,在于既然WeakMap里没有键值的迭代,只能通过钥匙才能取到相关的值,所以保证了内部的封装私有属性。这也是为什么我们前面07讲说到对象的私有属性的时候,可以用WeakMap来存储。
|
||||||
|
|
||||||
|
散列冲突:解决哈希碰撞的方式
|
||||||
|
|
||||||
|
其实解决哈希碰撞的几种方式也值得了解。上面我们已经介绍了几种通过哈希函数算法角度解决哈希碰撞的方式。下面,我们再来看看通过数据结构的方式是如何解决哈希冲突的。这里有几种基础方式,包含了线性探查法、平方探测法和二度哈希法。
|
||||||
|
|
||||||
|
线性探查法
|
||||||
|
|
||||||
|
我们先来说说线性探查法。用这种方式的话,当一个散列碰撞发生时,程序会继续往下去找下一个空位置,比如在之前例子中,7被南昌占用了,北京就会顺移到8。这样在存储的时候问题也许不大,但是在查找的时候会有一定的问题,比如当我们想要查找某个数据的时候,则需要在集群中迭代寻找。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
平方探测法
|
||||||
|
|
||||||
|
另外一种方式就是平方探测法。平方探测法用平方值来代替线性探查法中的往后顺移一位的方式,这样就可以做到基于有效的指数做更平均的分布。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
二度哈希法
|
||||||
|
|
||||||
|
第三种方式是二度哈希(Rehashing/Double-Hashing),也就是在第一次的哈希的基础上再次哈希。在下面公式里,x是第一次哈希的结果,R小于哈希表。假设每次迭代序列号是i,每次哈希碰撞通过i * hash2(x)来解决。
|
||||||
|
|
||||||
|
hash2(x) = R − (x % R)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
HashMap:Java是如何解决散列冲突的?
|
||||||
|
|
||||||
|
先把JS放在一边,其实我们也可以通过Java语言里一些更高阶的链式数据结构,来更深入了解下哈希碰撞的解决方式。如果你学过Java,可能有用到过HashMap、LinkedHashMap和TreeMap。那么Java中的HashMap和LinkedHashMap,那么Java中的这些数据结构有什么优势,分别是如何实现的?下面,我们可以来看看。
|
||||||
|
|
||||||
|
HashMap的底层逻辑是通过链表和红黑树实现的。它最主要解决的问题就是哈希碰撞。我们先来说说链表。它的规则是,当哈希函数生成的哈希值有冲突的时候,就把有冲突的数据放到一个链表中,以此来解决哈希碰撞。那你可能会问,既然链表已经解决了这个问题,为什么还需要用到红黑树?这是因为当链表中元素的长度比较小的时候,链表性能还是可以的,但是当冲突的数据过多的时候,它就会产生性能上的问题,这个时候用增删改查的红黑树来代替会更合适。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
散列加链表:基于双链表存值排序
|
||||||
|
|
||||||
|
了解完HashMap,再来看看LinkedHashMap。LinkedHashMap是在HashMap的基础上,内部维持了一个双向链表(Doubly Linked List),它利用了双向链表的性能特点,可以起到另外一个非常重要的作用,就是可以保持插入的数据元素的顺序。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
TreeMap:基于红黑树的键值排序
|
||||||
|
|
||||||
|
除了HashMap和LinkedHashMap,TreeMap也是Java一种基于红黑树实现的字典,但是它和HashMap有着本质的不同,它完全不是基于散列表的。而是基于红黑树来实现的。相比HashMap的无序和LinkedHashMap的存值有序,TreeMap实现的是键值有序。它的查询效率不如HashMap和LinkedHashMap,但是相比前两者,它是线程安全的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过这节课,你应该了解了如何通过哈希查找JS对象的内存地址。不过,更重要的是希望通过今天的学习,你也能更好地理解哈希、散列表、字典,这些初学者都比较容易混淆的概念。最后我们再来总结下吧。我们说字典(dictionary)也被称为映射、符号表或关联数组,哈希表(hash table)是它的一种实现方式。在ES6之后,随着字典(Map)这种数据结构的引入,可以用来实现字典。集合(Set)和映射类似,但是区别是集合只保存值,不保存键。举个例子,这就好比一个只有单词,没有解释的字典。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
今天的思考题是,我们知道Map是在ES6之后才引入的,在此之前,人们如果想实现类似字典的数据结构和功能会通过对象数据类型,那你能不能用对象手动实现一个字典的数据结构和相关的方法呢?来动手试试吧。
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
105
专栏/JavaScript进阶实战课/16为什么环形队列适合做Node数据流缓存?.md
Normal file
105
专栏/JavaScript进阶实战课/16为什么环形队列适合做Node数据流缓存?.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
16 为什么环形队列适合做Node数据流缓存?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
前面几讲讲完了栈这种数据结构,我们再来看看队列(queue)。队列对于你来说,可能不算是一种陌生的数据结构,和栈相反,列队通常遵循的是先进先出(FIFO,First In, First Out)的原则。你可以把它想象成在咖啡厅买咖啡时要排的队,基本是先到先得,最后来的最后买到,不能插队。如果你强行插队的话,那每个人的排队时长都会打乱。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
只是实现一个队列并不复杂,重要的是,你要理解队列在编程中的应用。既然我们在讲JS,就举几个身边的例子,比如我们的浏览器就是通过引擎来做线程和任务的管理。在使用Node的应用中,环形队列可以用来做数据流的缓存。这一讲,首先我们快速地了解下队列的核心,然后通过它在JS引擎了解它们的使用场景,最后我们通过学习一种特殊的环形队列来解答开篇的问题,就是为什么环形队列适合用来缓存数据流。
|
||||||
|
|
||||||
|
如何实现队列和双队列
|
||||||
|
|
||||||
|
首先,我们来看看如何实现一个简单的队列。数据结构中队列的核心思想和我们排队买票看电影一样,关键是谁排在前面,谁就可以先买到票。和排队一样,在数据结构中,我们可以有入队、出队的概念。入队(enqeue),顾名思义就是在队伍后面加了一个人排队。这里的实现和我们看的栈里面的入栈是类似的,所以也可以用push来完成。下面我们再来说说出队,按照先入先出的规则,排在最前面的人买完票了以后,就会出队(dequeue)。它的实现可以通过JavaScript中自带的shift,通过shift,我们可以去掉一个数组里面最开头的一个元素。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
除了队列,还有一个概念是双队列(deque)。虽然说通常我们排队的时候,都是遵循先进先出的规则,但是在有些特殊的情况下,也会有特例。比如大家在排队等车,看到一位女士带着很小的宝宝在大热天高温下等待,如果一队的人同意的话,大家一般会让她们排到前面。那么在JavaScript中呢,同样有一个unshift()的方法可以用来做到插队,我们在下面例子里把它叫做dequeAdd。还有另外一种情况就是如果有的人在队尾等不及了,也有可能离开,这样的话我们可以借用弹出栈的pop()来实现,在下面例子中,我们可以管它叫dequeRemove吧。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在JavaScript里面,我们可以通过下面的方式来实现一个队列。
|
||||||
|
|
||||||
|
class Queue {
|
||||||
|
constructor () {
|
||||||
|
this.queue = [];
|
||||||
|
}
|
||||||
|
enqueue(item) {
|
||||||
|
return this.queue.push(item);
|
||||||
|
}
|
||||||
|
dequeue() {
|
||||||
|
return this.queue.shift();
|
||||||
|
}
|
||||||
|
dequeAdd(item) {
|
||||||
|
return this.queue.unshift(item);
|
||||||
|
}
|
||||||
|
dequeRemove(item) {
|
||||||
|
return this.queue.pop(item);
|
||||||
|
}
|
||||||
|
peek() {
|
||||||
|
return console.log(this.queue[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
通过队列看浏览器任务管理
|
||||||
|
|
||||||
|
下面我们再来看看Chrome浏览器是如何通过队列来实现线程管理的。首先我们来了解下进程(process)和线程(thread)分别是什么,之间是什么关系。我们拿Chromium来举例,Chromium用的是多进程的架构(multi-process architecture)。我们在浏览器中每当打开一个新页面的时候,就是一个新的浏览器进程。
|
||||||
|
|
||||||
|
在一个浏览器进程里,会有多个线程。Chromium中有两个核心线程,一个是主线程,另外一个是IO线程。因为浏览器是面向前端用户的,所以对于它的架构设计来说,最主要的目标是让主线程和IO线程可以快速响应。为了达到这个目的,就要把阻塞IO或复杂运算分给其它的线程去处理,线程间通信问题通过消息传递来解决。
|
||||||
|
|
||||||
|
所以可以说Chromium用的是一个高并发,但不算是高并行的架构。对于页面加载的脚本中要执行的任务,会采用任务队列的方式通过事件循环给到UI主线程。如果问题是主线程可以解决的,就会处理,如果处理不了的,就会给到IO线程或特殊线程来处理,处理的结果会通过消息通信的方式给到主线程。
|
||||||
|
|
||||||
|
在浏览器进程中,除了主线程、IO线程和特殊线程外,还有一个用来处理通用任务的线程池。线程池有一个共享的任务队列。我们知道,在处理高并发时,人们通常通过锁结构来确保线程安全,而在Chromium线程管理当中,并不提倡用锁的结构,而是提倡序列或虚拟线程管理的概念。因为在Google看来,序列本身就带有线程安全性。这是怎么做到的呢?因为在虚拟线程管理中,只有当一个任务执行完,下一个任务才有可能被分配到线程池中的另一个工作线程来执行,所以下一个任务肯定是基于上一个任务的结果来执行的。所以虽然对于不同的工作线程来讲,它们之间的工作是并行的,但是对于一组需要串行处理的任务来说,它们的执行是有先后顺序的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这里我们可以打一个比方,比如我们在项目管理中,会有一个需求池,这个需求池就是我们的任务队列,而虚拟线程管理就如同一个项目经理。
|
||||||
|
|
||||||
|
项目经理的工作是根据任务和团队,来制定项目计划。比如根据需求,执行中有两个任务分别是设计和开发,这两个任务需要按顺序来,开发团队说你的设计UI没出来,我是不会开始写代码的,这时,作为项目经理,你会把这两个任务作为串行任务,也就是任务2的开始基于任务1的结束。虽然这解决了线程安全问题,但是同时项目经理也清楚投入到项目的生产力是有限的,怎么能通过生产关系的优化让资源更有效的被利用起来呢?
|
||||||
|
|
||||||
|
这时我们可以用迭代的方式。设计团队做迭代1中的设计,然后确保完成再交给开发团队来开发。这时当开发团队在做迭代1的开发时,设计团队已经在继续做迭代2的设计了。对于迭代1中设计和开发这两个任务来说,它们是按照序列串行的。同时,对于两个团队来讲,他俩的工作是并行的。这样既降低了项目风险,又可以做到一定程度的并发。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那么这样是说Chromium就完全不支持锁了吗?也不是。那什么时候可以用锁结构呢?同样举个例子,这就好比产品和研发两个员工(worker thread),产品宣讲完业务逻辑,研发在开发过程中,发现当时讨论的流程图有个问题,两个人需要同时修改一份流程文档,这个时候为了避免内容被相互覆盖,两个人商量好了,应该只有一个人去改。所以在Chromium中,通常当有多个线程(worker thread)可以访问一个数据资源,但是同一时间只允许一个线程来访问这些资源或数据的时候,会用到锁。在Chromium当中使用的是互斥锁(mutex)。
|
||||||
|
|
||||||
|
环形队列和线程间数据传递
|
||||||
|
|
||||||
|
说完了常见的队列,我们再来看在一种特殊的队列,就是环形队列。一个环形队列是首尾相连的。它也叫做环形缓冲区(ring buffer),可以用于进程或线程之间的数据传递。
|
||||||
|
|
||||||
|
回答开篇的问题:为什么环形队列适合做Node数据流缓存?你可能会问这样的队列为什么会有用?那是因为它最核心的好处是当一个数据元素被用掉后,其余数据元素不需要移动其存储位置。我们来看看它是怎么做到这一点的。对于一个环形队列来说,我们一般需要两个指针,一个是头指针,一个是尾指针。头指针指向的是下一个要加入的数据,尾指针指向下一个要读取的数据。当数据被读取时,我们就增加尾指针;当数据被写入的时候,我们就增加头指针。
|
||||||
|
|
||||||
|
举个例子,假设我们有一个16位的缓冲,第一步,我们加入了4位数据,头指针就移动到了3。如果再加3个的话,头指针就移动到了6。如果这时,我们读取了前4个,那么尾指针就会到4。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
用一个形象的比喻,大家如果在美国的一些餐厅吃过饭,可能会见过一个点餐轮盘。来餐厅的顾客一般会把想吃的东西写在纸上,然后放到轮盘上,厨师会按照顺序从轮盘上把点餐的菜单拿下来,然后来做饭。这就很像是一个环形队列。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那么在程序中这种环形队列如何实现呢?从实现的角度,一般会建立两个数组,一个是原数组用来定义环形队列的属性和方法,第二个是实际用来存放数据的环形队列数组。在原数组里面,主要存放3个关键属性,分别是头指针、尾指针和环形队列长度。同时,包含几个核心方法:原数组中属性的获取和设置,以及环形队列数组中数据的读和写。通过这种方式,就可以实现一个环形队列了。
|
||||||
|
|
||||||
|
下面我们可以来看看它在缓存数据流中的应用。这种环形队列通常会和“生产者,消费者”模式一起用,也通常会加一个互斥锁到环形队列的读、写方法里,用来实现互斥访问。如下图所示,我们可以看到有4个工作线程。2个是生产者,2个是消费者。 生产者负责写入数据,消费者读取数据。通过加锁的方式,在同一时间,只有一个生产者可以写入,在读的时候,也只有一个消费者可以读取。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在数据流这种大量数据持续进入到列队中,再从队列中取出做缓存处理的情况下,使用环形队列就大大增加了生产者和消费者之间协同合作的效率。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
在这一讲当中,我们通过对队列的原理介绍,学习了它在浏览器线程管理中的应用;之后通过对环形队列的的原理介绍,学习了它在缓存数据流中的应用。数据流缓冲在很多应用中都有体现,除了进程管理外,在网络和文件系统中,都会用到数据流缓冲。在网络中,字节数据都是按照包的大小分成块儿来传输的;在文件系统中,数据块儿都是根据内核的缓冲大小分成块儿来处理的。所以无论是HTTP的数据请求和反馈,还是文件到目的地的传输,这些数据的传输都会用到环形队列缓冲。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
在我们用互斥锁的时候,会发现它有一个劣势,就是共享资源,也就是环形队列每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程,这在某种意义上是一个悲观锁。除此之外,还有另外的一种方式是原子操作,它是针对某个值的单个互斥操作。你知道如何通过原子操作来实现环形队列缓冲吗?
|
||||||
|
|
||||||
|
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
177
专栏/JavaScript进阶实战课/17如何通过链表做LRU_LFU缓存?.md
Normal file
177
专栏/JavaScript进阶实战课/17如何通过链表做LRU_LFU缓存?.md
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
17 如何通过链表做LRU_LFU缓存?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
前面我们在第10-13讲讲过了数组这种数据类型,以及通过它衍生出的栈和队列的数据结构。之后,我们在讲到散列表的时候,曾经提到过一种链表的数据结构。今天,我们就来深入了解下链表和它所支持的相关的缓存算法。链表有很多使用场景,最火的例子当属目前热门的区块链了。它就是用了链表的思想,每一个区块儿之间都是通过链表的方式相连的。在链表结构里,每一个元素都是节点,每个节点有自己的内容和相连的下一个元素的地址参考。
|
||||||
|
|
||||||
|
既然我们讲的是JavaScript,还是回到身边的例子,就是缓存。无论是我们的操作系统,还是浏览器,都离不开缓存。我们把链表和散列表结合起来,就可以应用于我们常说的缓存。那这种缓存是如何实现的呢?接下来就先从链表说起吧。
|
||||||
|
|
||||||
|
如何实现单双循环链表
|
||||||
|
|
||||||
|
单链表
|
||||||
|
|
||||||
|
下面我们先来看看一个单向链表是怎么实现的。链表就是把零散的节点(node)串联起来的一种数据结构。在每个节点里,会有两个核心元素,一个是数据,一个是下一个节点的地址,我们称之为后继指针(next)。在下面的例子里,我们先创建了一个节点(node),里面有节点的数据(data)和下一个节点的地址(next)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在实现上,我们可以先创建一个节点的函数类,里面包含存储数据和下一节点两个属性。
|
||||||
|
|
||||||
|
class Node {
|
||||||
|
constructor(data){
|
||||||
|
this.data = data;
|
||||||
|
this.next = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
之后,我们再创建一个链表的函数类。在链表里,最常见的方法就是头尾的插入和删除,这几项操作的复杂度都是\(O(1)\)。应用在缓存当中的时候呢,通常是头部的插入,尾部删除。但倘若你想从前往后遍历的话,或是在任意位置做删除或插入的操作,那么相应的复杂度就会是\(O(n)\)。这里的重点不是实现,而是要了解链表里面的主要功能。因此我没有把所有代码都贴在这里,但是我们可以看到它核心的结构。
|
||||||
|
|
||||||
|
class LinkedList {
|
||||||
|
constructor(){
|
||||||
|
this.head = null;
|
||||||
|
this.size = 0;
|
||||||
|
}
|
||||||
|
isEmpty(){ /*判断是否为空*/ }
|
||||||
|
size() { /*获取链表大小*/ }
|
||||||
|
getHead() { /*获取链表头节点*/ }
|
||||||
|
indexOf(element) { /*获取某个元素的位置*/ }
|
||||||
|
insertAtTail(element) { /*在表尾插入元素*/ }
|
||||||
|
insertAt(element, index) { /*在指定位置插入*/ }
|
||||||
|
remove(element) { /*删除某个元素*/ }
|
||||||
|
removeAt(index) { /*在指定位置删除*/ }
|
||||||
|
getElementAt(index) { /*根据某个位置获取元素*/ }
|
||||||
|
removeAtHead(){ /*在表头位置删除元素*/ }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
注意观察的你,可能会发现,最后一个节点是null或undefined的,那它是做什么的呢?这个是一个哨兵节点,它的目的啊,是对插入和删除性能的优化。因为在这种情况下,插入和删除首尾节点的操作就可以和处理中间节点用同样的代码来实现。在小规模的数据中,这种区别可能显得微不足道。可是在处理缓存等需要大量频繁的增删改查的操作中,就会有很大的影响。
|
||||||
|
|
||||||
|
双链表
|
||||||
|
|
||||||
|
说完单链表,我们再来看看双链表。在前面单链表的例子里,我们可以看到它是单向的,也就是每一个节点都有下一个节点的地址,但是没有上一个的节点的指针。双链表的意思就是双向的,也就是说我们也可以顺着最后一个节点中关于上一个节点的地址,从后往前找到第一个节点。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
所以在双链表节点的实现上,我们可以在单链表基础上增加一个上一节点指针的属性。同样的,双链表也可以基于单链表扩展,在里面加一个表尾节点。对于从后往前的遍历,和从前往后的遍历一样,复杂度都是\(O(n)\)。
|
||||||
|
|
||||||
|
class DoublyNode extends Node {
|
||||||
|
constructor(data, next, prev) {
|
||||||
|
super(data, next);
|
||||||
|
this.prev = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DoublyLinkedList extends LinkedList {
|
||||||
|
constructor() {
|
||||||
|
this.tail = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
循环链表
|
||||||
|
|
||||||
|
我们接下来再来看看循环链表(circular list)。循环链表,顾名思义,就是一个头尾相连的链表。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
如果是双向循环列表的话,就是除了顺时针的头尾相接以外,从逆时针也可以循环的双循环链表。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
如何通过链表来做缓存
|
||||||
|
|
||||||
|
了解了单双循环链表后,现在我们回到题目,那我们如何能通过链表来做缓存呢?我们先了解下缓存的目的。缓存主要做的就是把数据放到临时的内存空间,便于再次访问。比如数据库会有缓存,目的是减少对硬盘的访问,我们的浏览器也有缓存,目的是再次访问页面的时候不用重新下载相关的文本、图片或其它素材。通过链表,我们可以做到缓存。下面,我们会看两种缓存的算法,他们是最近最少使用(LRU,least recently used)和最不经常使用(LFU,least frequently used),简称LRU缓存和LFU缓存。
|
||||||
|
|
||||||
|
一个最优的缓存方案应该把最不可能在未来访问的内容换成最有可能被访问的新元素。但是在实际情况中,这是不可能的,因为没人能预测未来。所以作为一个次优解,在缓存当中,通常会考虑两个因素,一个是时间局部性(Temporal Locality),我们认为一个最近被访问的内存位有可能被再次访问;另外一个因素是空间局部性(Spatial Locality),空间局部性考虑的是一个最近被访问的内存位相近的位置也有可能被再次访问。
|
||||||
|
|
||||||
|
在实际的使用中,LRU缓存要比LFU的使用要多,你可能要问为什么?这里我先卖个关子,咱们先看看这两种方法的核心机制和实现逻辑。之后我们再回到这个问题。
|
||||||
|
|
||||||
|
LFU 缓存
|
||||||
|
|
||||||
|
我们先来看看LFU缓存。一个经典的LFU缓存中,有两个散列表,一个是键值散列表,里面存放的是节点。还有一个是频率散列表,里面根据每个访问频率,会有一个双链表,如下图中所示,如果键值为2和3的两个节点内容都是被访问一次,那么他们在频率1下面会按照访问顺序被加到链表中。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在这里,我们看一个LFU关键的代码实现部分的说明。LFU双链表节点,可以通过继承双链表的节点类,在里面增加需要用到的键值,除此之外还有一个计数器来计算一个元素被获取和设置的频率。
|
||||||
|
|
||||||
|
class LFUNode extends DoublyNode {
|
||||||
|
constructor(key) {
|
||||||
|
this.key = key;
|
||||||
|
this.freqCount = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LFUDoublyLinkedList extends LinkedList {
|
||||||
|
constructor() {/* LFU 双链表 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
前面说到一个LFU缓存里面有两个散列表,一个是键值散列表,一个是频率散列表。这两个散列表都可以通过对象来创建。键值散列表保存着每个节点的实例。频率散列表里有从1到n的频率的键名,被访问和设置次数最多的内容,就是n。频率散列表里每一个键值都是一个双向链表。
|
||||||
|
|
||||||
|
那围绕它呢,这里有3个比较核心的操作场景,分别是插入、更新和读取。
|
||||||
|
|
||||||
|
对于插入的场景,也就是插入新的节点,这里我们要看缓存是否已经满了,如果是,那么在插入时它的频率是1。如果没有满的话,那么新的元素在插入的同时,尾部的元素就会被删除。
|
||||||
|
|
||||||
|
对于更新场景,也就是更新旧的节点,这时,这个元素会被移动到表头。同时,为了计算下一个被删除的元素,最小的频率minFreq会减1。
|
||||||
|
|
||||||
|
对于读取场景,也就是获取节点的动作,缓存可以返回节点,并且增加它的调用频率的计数,同时将这个元素移动到双链表的头部。同样与插入场景类似,最后一步,最低频率minFreq的值会被调整,用来计算下次操作中会被取代的元素。
|
||||||
|
|
||||||
|
class LFUCache {
|
||||||
|
constructor() {
|
||||||
|
this.keys = {}; // 用来存储LFU节点的键值散列表
|
||||||
|
this.freq = {}; // 用来存储LFU链表的频率散列表
|
||||||
|
this.capacity = capacity; // 用来定义缓存的承载量
|
||||||
|
this.minFreq = 0; // 把最低访问频率初始设置为0
|
||||||
|
this.size = 0; // 把缓存大小初始设置为0
|
||||||
|
}
|
||||||
|
set() {/* 插入一个节点 */}
|
||||||
|
get() {/* 读取一个节点 */}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
LRU 缓存
|
||||||
|
|
||||||
|
以上是LFU的机制和核心实现逻辑,下面我们再来看看最近最少使用(LRU least recently used)。LRU缓存的目的是在有限的内存空间中,当有新的内容需要缓存的时候,清除最老的内容缓存。当一个缓存中的元素被访问了,这个最新的元素就会被放到列表的最后。当一个没有在缓存中的内容被访问的时候,最前面的也就是最老的内容就会被删除。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在LRU缓存的实现中,最需要注意的是要追踪哪个节点在什么时间被访问了。为了达到这个目的,我们同样要用到链表和散列表。之所以要用到双向链表,是因为需要追踪在头部需要删除的最老的内容,和在尾部插入最新被访问的内容。
|
||||||
|
|
||||||
|
从实现的角度看,LRU当中的节点和LFU没有特别大的差别,也需要一个键值散列表,但是不需要频率散列表。LRU缓存可以通过传入承载量capacity参数来定义可以允许缓存的量。同样和LFU类似的,LRU也需要在链表头部删除节点和链表尾部增加节点的功能。在此基础之上,有着获取和设置的两个对外使用的方法。所以总体看来,LRU的实现和LFU相比是简化了的。
|
||||||
|
|
||||||
|
class LRUNode extends DoublyNode {
|
||||||
|
constructor(key) {
|
||||||
|
this.key = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LRUCache {
|
||||||
|
constructor() {
|
||||||
|
this.keys = {}; // 用来存储LFU节点的键值散列表
|
||||||
|
this.capacity = capacity; // 用来定义缓存的承载量
|
||||||
|
this.size = 0; // 把缓存大小初始设置为0
|
||||||
|
}
|
||||||
|
set() {/* 插入一个节点 */}
|
||||||
|
get() {/* 读取一个节点 */}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
如果从直觉上来看,你可能会觉得对比LRU,似乎LFU是更合理的一种缓存方法,它根据访问的频率来决定对内容进行缓存或清除。回到上面的问题,为什么在实际应用中,更多被使用的是LRU呢?这是因为一是短时间高频访问本身不能证明缓存有长期价值,二是它可能把一些除了短时间高频访问外的内容挤掉,第三就是刚被访问的内容也很可能不会被频繁访问,但是仍然可能被重复访问,这里也增加了它们被意外删除的可能性。
|
||||||
|
|
||||||
|
所以总的来说,LFU没有考虑时间局部性,也就是“最近被访问的可能再次被访问”这个问题。所以在实际的应用中,使用LRU的概率要大于LFU。希望通过这节课,你能对链表和散列表的使用有更多的了解,更主要的是,当你的应用需要写自己的缓存的时候,也可以把这些因素考虑进去。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
下面到了思考题时间,虽然说LFU的应用场景比较少,但是它在某些特定场合还是很有用处的,你能想到一些相关的场景和实现吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
189
专栏/JavaScript进阶实战课/18TurboFan如何用图做JS编译优化?.md
Normal file
189
专栏/JavaScript进阶实战课/18TurboFan如何用图做JS编译优化?.md
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
18 TurboFan如何用图做JS编译优化?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
今天我们来看下图(graph)这种数据结构,图是一个抽象化的网络结构模型。你可能会问有什么算法应用会用到图这种结构吗?其实很多,像我们经常用的社交媒体(比如国内的微博、微信,或者国外的脸书、领英)中的社交图谱,都可以通过图来表达。另外,图也能用来表达现实世界中的路网、空网以及虚拟的通信网络。
|
||||||
|
|
||||||
|
图在JS引擎的编译器中作用是非常大的。如果说整个V8的编译器TurboFan都基于图也毫不夸张。我们既可以通过图这种数据结构,对编译的原理有更深的理解,在我们了解编译的同时,又可以对相关的数据结构和算法有更深入的认识。
|
||||||
|
|
||||||
|
图的结构
|
||||||
|
|
||||||
|
下面我们先来看一下图的结构吧。图就是由边(edge)连接的节点(vertax),任何一个二分的关系都可以通过图来表示。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那我们说的TurboFan是怎么利用图来做编译的呢?在开始前,我们先来了解下编译流程和中间代码。
|
||||||
|
|
||||||
|
编译流程:中间代码
|
||||||
|
|
||||||
|
IR,也就是中间代码(Intermediate Representation,有时也称 Intermediate Code,IC)。从概念层面看,IR可以分为HIR(Higher IR)、MIR(Middle IR)和LIR(Lower IR),这几种高、中、低的中间代码的形式。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
从上面的图中,我们可以看到在整个编译过程中,可以把它分成前中后三个端。前端主要做的是词法、语法和语义的分析,这个我们在之前的章节也有讲过。中端会做相关的优化工作,最后后端的部分就生成了目标代码。
|
||||||
|
|
||||||
|
前面我们说了,中间代码可以分为高、中、低三个不同的抽象层次。HIR主要用于基于源语言做的一些分析和变换,比较典型的例子就是用于语义分析的的AST(Abstract Syntax Parser)语法树。MIR则是独立于源语言和 CPU 架构来做一些分析和优化,比较典型的例子就是三地址代码 TAC(Three Address Code)和程序依赖图 PDG(Program Dependency Graph),它们主要用于分析和优化算法的部分。LIR这一层则是依赖于 CPU 架构做优化和代码生成,其中比较典型的例子就是有向无环图 DAG(Directed Ayclic Graph),它的主要目的是帮助生成目标代码。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在编译流程中分析的这一段里,有一个重要的分析就是控制流分析(CFA,Control Flow Analysis)和数据流分析(DFA, Data Flow Analysis),而我们后面要说的节点之海(SoN,Sea of Node)就是可以同时反映数据流和控制流的一种数据结构,节点之海可以减少CFA和DFA之间的互相依赖,因而更有利于全局优化。
|
||||||
|
|
||||||
|
在编译流程中优化的这一段里,也有几个重要的概念,分别是通用优化、对象优化和函数式优化。比如循环优化就属于通用的优化;内联和逃逸属于关于对象的优化。对于函数式编程而言,比较重要的就是之前,我们在说到迭代和递归的时候,提到过的循环和尾递归的优化。
|
||||||
|
|
||||||
|
在流程中最后生成目标代码的这一段里,重点的任务是寄存器分配和指令的选择和排序。
|
||||||
|
|
||||||
|
今天,我们重点来看一下在分析过程中,图和节点之海的原理,以及为什么说节点之海有利于全局的优化。
|
||||||
|
|
||||||
|
中端优化:分析和优化
|
||||||
|
|
||||||
|
下面我们从中端的优化说起,通过IR的结构你可能可以看出,“中间代表”可能是比“中间代码”更准确的一种描述。因为IR更多的是以一种“数据结构”的形式表现,也就是我们说的线性列表、树、图这些数据结构,而不是“代码”。今天我们重点说的这种数据结构呢,就是TurboFan用到的节点之海 (SoN,Sea of Node)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
性能一直是 V8 非常关注的内容。节点之海(SoN)的概念首次提及,是HotSpot创始人Cliff Click在一篇93年的论文中提出的。V8的前一代CrankShaft编译器是基于HotSpot C1实现的,V8现在用的TurboFan编译器是基于第二代HotSpot C2实现的升级版。TurboFan使用了优化了的SoN IR与流水线相结合生成的机器码,比使用 CrankShaft JIT 产生的机器码质量更高。与 CrankShaft 相比,TurboFan采用了更多的优化和分析,比如控制流的优化和精确的数值范围分析,所有这些都是以前无法实现的。节点之海的加入更有利于做全局优化。下面我们就来了解一下节点之海的结构和格式。
|
||||||
|
|
||||||
|
SoN结构:格式和表达
|
||||||
|
|
||||||
|
首先我们可以看一下SoN的结构,整个的SoN只有节点和边构成。除了值以外,所有的运算也都是用节点来表示的。在TurboFan的可视化工具Turbolizer中,使用了不同颜色来区分不同类型的节点。浅蓝色就代表了值,深蓝色代表了中间层的操作,例如运算。就像下面的加法运算,边是用来表示运算之间的依赖关系,表达的是5+x。这种依赖也就限制了先后顺序,比如5+x的加法运算是依赖5和x两个值节点的,所以我们在顺序上可以允许5和x是先后关系,但是必须出现在加法运算前。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
SoN的格式:静态单赋值
|
||||||
|
|
||||||
|
说完了结构,我们再来看看格式。SoN是符合静态单赋值(SSA,static single assignment)格式的。什么是静态单赋值呢?顾名思义,就是每个变量只有唯一的赋值。比如下面的例子里,第一个操作是x等于3乘以7,之后我们给x加3。这时,x变量就出现了两次。所以TurboFan在创建图的时候会给本地变量做重命名,在改完之后就是3这个值的节点同时有两个操作符指向它,一个是乘以7的结果,一个是结果再加上3。在SoN中没有本地变量的概念,所以变量被节点取代了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
数据流表达
|
||||||
|
|
||||||
|
我们前面说过,在分析的过程中,有一个重要的分析就是控制流分析(CFA,Control Flow Analysis)和数据流分析(DFA, Data Flow Analysis)。在数据流中,节点用来表达一切运算,包括常量、参数、数学运算、加载、存储、调用等等。
|
||||||
|
|
||||||
|
关于数据流的边,有两个核心概念,一是实线边表达了数据流的方向,这个决定了输入和输出的关系,下面实线表达的就是运算输出的结果。二是虚线边影响了数据流中的相关数据的读写状态,下面虚线边表示运算读写状态的指定。
|
||||||
|
|
||||||
|
关于WAW(Write-After-Write,写后再写)、WAR(Write-After-Read,先读后写)和RAW(Read-After-Write,先写后读)的读写状态,我们可以看看是如何实现的。我们可以看到SoN中的虚线边可以用来表达不同读写状态的影响。比下面obj对象的val属性状态是可变的,完成加val加3的操作后,通过先读再写存入,之后的结果值先写后读。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
控制流表达
|
||||||
|
|
||||||
|
我们前面说过,从数据流的角度来看,节点和边表达了一切。而在控制流中,这种说法也是同样成立的。通过下面的例子,我们可以看到,控制中的节点包括开始、分支、循环、合并以及结束等等,都是通过节点来表示。
|
||||||
|
|
||||||
|
在Turbolizer的可视化图中,用黄色节点代表了控制流的节点。下面是几个单纯看数据流的例子,我们可以看到从最简单的只包含开始结束的直线程序,到分支到循环都可以通过节点之海来表达。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
下面我们来看看在和数据流结合后的一个最简单的例子。这里我们看到了,为了区分不同类型的边,可以通过黑色实线、灰色实线和虚线边分别代表控制流、数据流和影响关系。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
最后,我们再加上合并,可以看到一个更完整的例子。这里你会看到一个phi指令,这个指令是做什么的呢?它会根据分支判断后的实际情况来确定x的值。因为我们说过,在SSA(静态单赋值)的规则中,变量只能赋值一次,那么我们在遇到分支后的合并,就必须用到phi。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
分析:变量类型和范围
|
||||||
|
|
||||||
|
我们讲了中间代码的重要作用是分析和优化,为什么要做变量类型和范围的分析呢?这里主要要解决三个问题。第一,我们说JavaScript是动态而不是静态类型的,所以虽然值有固定的数据类型,可变量是没有固定的数据类型的。举个例子,我们可以说 var a = 25,25本身是不变的数字类型,但是如果我们说a + “years old”, 那么变量a的类型就变成了字符串。第二,所有的数学运算都是基于64比特的,然而在实际的应用中,大多数的程序其实用不了这么多比特,大多都小于32比特,并且这里还有很多如NaN、Infinity、-Infinity、-0.0等特殊的值。第三,是为了支持如asm.js这样的JS子集中的注释,以及将上一点提到的如NaN、Infinity这些特殊值截断成0整数这样的需求。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
优化:基于节点海的优化
|
||||||
|
|
||||||
|
说完以上这三点我们要做变量类型和范围的分析。我们再来说说优化,几乎所有的优化都是在节点上完成的,如果一个节点可以被访问到就代表是活的,如果节点不能从结束节点访问到就是死节点,包含死的控制、效果和运算。它们在编译过程中大多数的阶段是看不到死节点的,并且无效节点也不会被放到最后的调度里。
|
||||||
|
|
||||||
|
优化的算法就太多了,下面我们说几种核心的优化方案。
|
||||||
|
|
||||||
|
方案一:常量提前,简化计算
|
||||||
|
|
||||||
|
在优化的方案中,最基本的就是常量提前和简化计算的优化方式了。
|
||||||
|
|
||||||
|
在常量提前中,比较经典的优化就是常数折叠(constant folding),顾名思义,就是把常数加在一起,比如在下面的第一个例子中,我们把3+5的操作直接折叠为8。
|
||||||
|
|
||||||
|
在简化计算中,比较有代表性的例子就是强度折减(strength reduction)。比如在下面的第二个例子中,x+0和x没有区别,就可以简化成x。再比如下面第三个例子中,可以把x*4改为x<这样的移位运算。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
方案二:去重计算
|
||||||
|
|
||||||
|
在去重计算中,最常见的是值编号(value numbering)。意思就是在系统里把等于相同的值赋予同一个编号。比如当sin、加法以及LoadField的运算的结果值是一样的时候,那么就只记录一次即可。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
方案三:控制流优化
|
||||||
|
|
||||||
|
针对控制流,也有很多不同的优化方式。下面是几个例子,包含了分支折叠、合并折减、控制折减。通过这几个例子就可以看到,无论流程本身,还是流程中的分支和合并在优化后,都简化了很多。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Lowering:语言的层级
|
||||||
|
|
||||||
|
前面我们看到了,TurboFan的Tubolizer会用不同颜色来表示不同类型的节点。包括代表值的浅蓝色节点、代表流程的黄色节点,以及代表运算的深蓝色语言节点,除了深蓝色以外,还有两种语言的节点,一种是上层的红色的JavaScript语言的节点,还有就是更接近底层的机器语言的绿色节点。基本上层级越高,对人来说可读性就越高;层级越低,则越接近机器语言。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
我们把这种由高到低的过程,叫做lowering。所以优化的过程,也会是一个不断lowering折减的过程。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
后端优化:指令和寄存
|
||||||
|
|
||||||
|
关于中端优化的部分先讲到这,下面我们再来看看后端优化,如何生成目标代码,涉及到寄存器分配和指令选择排序。
|
||||||
|
|
||||||
|
指令排序
|
||||||
|
|
||||||
|
先说说指令排序。SoN节点之海最后排序的结果会被放入一个CFG(Control Flow Graph)程序控制图。这里有三个概念,第一是控制支配,第二是减少寄存压力,第三是循环优化。
|
||||||
|
|
||||||
|
首先我们来看第一点,在这个过程中,首先是把固定的节点比如phi、参数等都放到CFG中。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
之后会用到支配树来计算支配关系,接着会把剩余的节点按照支配关系从SoN节点之海输出,输入到CFG程序控制图中。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
第二点,那么在编译过程中如何缓解寄存压力呢?我们前面说的SSA也是通过支配树来实现的。排序后,节点之海完全变成了一个程序控制图。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
最后我们来看第三点,循环优化。在这个过程里尽量对循环中的代码做提升,这个过程叫做Loop Invariant Code Motion,也就是我们常说的hoisting。你可能问这个过程在哪儿?其实这一步在分析优化的时候已经做了,在这里,只是纳入了优化后的结果。
|
||||||
|
|
||||||
|
指令选择
|
||||||
|
|
||||||
|
下面我们再来看看指令选择。从理论上讲,指令选择可以用最大吞噬。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
但是实际上指令选择的顺序,是从程序控制图中相反的顺序,也就是从基本块儿中自下而上地移动标的位置。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
寄存器分配
|
||||||
|
|
||||||
|
前面,我们说过,在分析和优化结束后,在编译过程的后端,要最后生成机器码,这个过程中,一件主要的工作就是寄存器的分配。TurboFan 使用线性扫描分配器和实时范围分割来分配寄存器和插入溢出代码。SSA 形式可以在寄存器分配之前或之后通过显式移动进行解构。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
寄存器分配的结果是用真实寄存器代替虚拟寄存器的使用,并在指令之间插入溢出代码。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这一讲可以算是比较硬核了,但是如果你能读到这里,证明你已经坚持下来了。从V8对节点之海的运用,我希望你不仅是了解了这种数据结构,更是对整个编译的流程有了更深入的理解。而且在这个过程中,我们也可以看到,我们写的代码在编译过程中最后被优化的代码不一定是一开始写的代码了。这既说明了我们在写代码的时候可以更关注可读性,因为我们没有必要为了机器会优化的内容而操心,从另外一个角度来看呢,有些开发者对性能有极度的追求,也可能写出更接近优化后的代码,但这样做是会牺牲一些可读性的。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
如果你有用过Java的Graal会发现这个编译器的IC用的也是基于SoN的数据结构。那么你能说出HotPot、Graal以及TurboFan在SoN上的相同和不同之处吗?
|
||||||
|
|
||||||
|
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
参考
|
||||||
|
|
||||||
|
|
||||||
|
TurboFan JIT Design by Ben L. Titzer from Google Munich
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
188
专栏/JavaScript进阶实战课/19通过树和图看如何在无序中找到路径和秩序.md
Normal file
188
专栏/JavaScript进阶实战课/19通过树和图看如何在无序中找到路径和秩序.md
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
19 通过树和图看如何在无序中找到路径和秩序
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在算法中,最常见的两个操作莫过于排序和搜索了。前面我们通过数组了解了在线性的数据结构中不同的排序算法。之后,我们又通过哈希算法了解了散列表这种比较特殊的线性表,了解了它是如何与链表结合,用空间换时间地支持索引和查找的。
|
||||||
|
|
||||||
|
在现实的生活中,我们可以看到信息并不总是有序的,我们在讲到函数式编程的时候有提到过,函数思想的一个维度就是如何处理未知和混沌。那么函数中的数据结构和算法呢,作为现实世界的映射,也要能处理这样复杂的情况。今天,我们来看两种支持我们处理这种未知和混沌的非线性的数据结构。一种是树,另外一种是图。
|
||||||
|
|
||||||
|
我们知道一些大的咨询公司如麦肯锡,经常用到“金字塔原理”来做解决方案。它的核心原理呢,讲的就是我们平时接收的信息是杂乱无章的网状结构的,而我们要做的是在杂乱无章的信息中梳理出“金字塔”式的树形信息结构,最后呢,再用线性的“讲故事”的方式讲述出来。这就像极了我们在算法中用到的各种数据结构。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
今天我们就深入来看看图和树这两种数据结构。我们先从图说起,图就是一种非线性的数据结构。我们生活中有很多无序的网络组织都可以用图来表示,比如社交网络,我们的互联网通信、城市的道路、网站的链接。如果用我们前端开发的视角来看的话,不同依赖资源本身也可以被看做是无序的,而例如我们前端经常用到的webpack的模块加载功能就是一个在无序中建立秩序,帮助我们厘清资源之间相关依赖关系的工具,这种功能就可以通过拓扑排序来实现。我们可以先从图这种数据结构本身说起,来一步步看下它是怎么做到的。
|
||||||
|
|
||||||
|
通过拓扑排序建立资源依赖
|
||||||
|
|
||||||
|
首先,我们来看下图的结构。
|
||||||
|
|
||||||
|
深入了解图的类型和结构
|
||||||
|
|
||||||
|
在上一节当中,我们说过一个图就是由边(edge) 相连的节点(node) 组成的。如果延伸来看,我们可以将通过一条线相连接的两个节点叫做相邻节点(adjacent vertices)。同时,我们可以把一个节点连接的数量叫做度(degree)。一个边也可以加权(weighted)。一条路径(path)就是一组相邻节点的序列。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
一条可以回到原点的路径是循环图(cyclic graph)。一个没有可以回到原点的路径的图被称作无环图(acyclic graph)。如果图之间的边是有指向的就会被称为是有向图(directed graph),反之,如果图之间的边没有指向,就被称之为无向图(undirected graph)。如果一个图是有向无环的,就叫做我们上一讲提到过的有向无环图(DAG,directed acyclic graph)。
|
||||||
|
|
||||||
|
一种用来存储图的方式是通过邻接矩阵(adjacency matrix)。如果一个图的相邻节点较少就被称为稀疏图(sparse graph);相反的如果一个图的相邻节点较多的话,就被称为稠密图(dense graph)。对于稀疏图来说,用邻接矩阵来存储可能就会造成资源的浪费,因为这个矩阵中可能为了几个节点来维护一个较高的空间复杂度。同样,邻接矩阵在支持节点的插入删除也具有劣势。所以另外一种用来存储图的方式就是通过邻接表(adjacency list)。这种数据结构既可以用数组,也可以用链表、 哈希或者字典来表示,所以可以和邻接矩阵形成有效的互补。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
对图的遍历有两种经典的方式,一种是广度优先搜索(BFS,breath first search),另外一种是深度优先搜索(DFS,depth first search)。广度优先搜索最经典的使用场景就是寻找最短路径(shortest path)的场景了。而深度优先搜索呢,就是我们前面说到的拓扑排序(topological sorting)可以用到的一种遍历方式。这里,我们可以先从广度优先搜索开始看起。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
如何在选择中找到最短路径
|
||||||
|
|
||||||
|
最短路径在生活中非常常见,比如我们常用的地图。常见的最短路径的算法有迪杰斯特拉Dijkstra和弗洛伊德-沃舍尔Floyd-Warshall。下面我们可以选Dijkstra(迪杰斯特拉)算法来看看。如图所示,假设我们要计算从A到B、C、D、E和Z的最短距离。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Dijkstra的算法先将所有的距离初始化为无限dist[i] = INF;已访问的数组为否visited[i] = false,然后把从源头到自己的距离设置为零dist[src] = 0。接下来,为了找到最短距离,我们寻找还未被处理的节点中最短的一个minDist(dist, visited),然后将它标记为已访问visited[u] = true;当找到并设置最短距离dist[u] + graph[u][v]后,所有路径的数组会被返回。下面是简化后的代码:
|
||||||
|
|
||||||
|
const dijkstra = (graph, src) => {
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
dist[i] = INF;
|
||||||
|
visited[i] = false;
|
||||||
|
}
|
||||||
|
dist[src] = 0;
|
||||||
|
for (let i = 0; i < length - 1; i++) {
|
||||||
|
var u = minDist(dist, visited);
|
||||||
|
visited[u] = true;
|
||||||
|
for (let v = 0; v < length; v++) {
|
||||||
|
if (...dist[u] + graph[u][v] < dist[v]) {
|
||||||
|
dist[v] = dist[u] + graph[u][v];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dist;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
如何在无序中找到资源依赖
|
||||||
|
|
||||||
|
回到开篇的问题,比如我们在用webpack或类似的工具做资源打包的时候,要知道模块间的依赖关系是很重要的。如果我们把这个命题抽象出来看,就是对于有向图来说,要知道如何在混乱中建立秩序,分析出哪个节点先被处理是很重要的。所以下面我们就来看看拓扑排序(topological sorting)是如何做到对资源依赖的管理的。这里其中一个方法是会用到深度优先DFS的遍历。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
其实简单地来说,拓扑排序用的就是从一个节点开始,进行深度优先的遍历,直到和它相连的每个顶点都用递归方式穷尽为止。每一次递归中的节点被加到一个访问过的visited集合中,以此类推,最后,在递归结尾,把节点用unshift以相反的顺序插到数组头部。
|
||||||
|
|
||||||
|
dfs = function(v, visited, stack) {
|
||||||
|
visited.add(v);
|
||||||
|
for (var item in this.edges[v]) {
|
||||||
|
if (visited.has(item) == false) {
|
||||||
|
this.topologicalSortUtil(item, visited, stack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack.unshift(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
dfsTopoSort = function() {
|
||||||
|
var visited = {},
|
||||||
|
stack = [];
|
||||||
|
for (var item in this.edges) {
|
||||||
|
if (visited.has(item) == false) {
|
||||||
|
this.dfs(item, visited, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stack;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
通过字典树建立Web API的路由
|
||||||
|
|
||||||
|
说完了图,下面,我们再来深入看下树的结构和它所支持的算法。
|
||||||
|
|
||||||
|
深入了解树的类型和结构
|
||||||
|
|
||||||
|
树(tree) 可以说是非常常见的一种数据结构了。它和我们平时看到的树没有区别,在树根树干上,有不同的分支和叶子。这些分支和叶子在树结构里就是节点(node)。只是我们在数据结构中,一般可视化的画一个树的时候,会把它的头尾倒过来。大家应该都见过公司的组织架构,这就可以被理解成树状结构了,它就是一个自上而下的节点组成的。在树的顶端,就是公司的CEO,这个节点叫做根(root)。在前端,我们使用的HTML也是一个非常典型的树形结构,在下的每一个元素都有父节点和子节点。我们前面讲过的编译原理中的AST语法树也是一种树的结构。
|
||||||
|
|
||||||
|
在树型的结构中,有二叉树(binary tree)和二叉查找树(BST,binary search tree)。虽然它们写法类似,但代表的是不同的意思。二叉树是最多只有2个子节点的树,而二叉查找树是指一个左节点的值小于右节点的树。二叉树里面分为满二叉树(full binary tree)和完全二叉树(complete binary tree)。在二叉树的遍历中,分为前中后三种顺序的遍历。下面我们依次来看一下。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在二叉查找树中有三种遍历的方式。第一种是中序遍历(in-order traversal),这种方法是对节点展开的从小到大的遍历,它的遍历顺序是左、根、右;第二种是前序遍历(pre-order traversal),这种方法是在先访问根节点再访问子节点,它的遍历顺序是根、左、右;第三种是后序遍历(post-order traversal),这种方法是在访问子节点后访问它的父节点,它的遍历顺序是右、左、根。除了这三种以外,还有层次遍历(level order traversal),这种方式就是上面我们讲到图的时候讲到的深度优先搜索(DFS,depth first search)的原理。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
二叉查找树有一个问题,就是当这个树的一个分支过深的时候,在增加、减少和查找的时候,可能会出现性能上的问题。所以在二叉查找树的基础上,有了AVL树(AVL tree,Adelson-Velskii and Landi’s tree)和红黑树(Red-Black tree),它们都属于平衡二叉树(balanced binary tree)。对于AVL树来说,它的左边和右边最多就差一级。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
为了在插入后保持平衡,AVL树会通过左旋或右旋的方式来调整。比如以上的例子就是左旋,下面的例子中,我们也可以看到右旋。在理想的状态下,AVL的复杂度是\(O(log2(n))\)。但是当有频繁的插入、删除等操作的时候,效率就会下降。在极端情况下它的复杂度会退化到\(O(n)\)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
虽然AVL的查询效率很高,但是为了保持节点插入或者删除后的平衡所进行的旋转操作,可能会导致复杂度的增加。这时红黑树的出现,就可以避免类似问题。那红黑树是怎么做到保持较低复杂度的呢?下面,我们来看看。红黑树的特点是每个节点都是红色或黑色的。根节点和所有叶子节点都是黑色的。
|
||||||
|
|
||||||
|
如果一个节点是红色的,那么它的两个子节点都是黑色的。不能有两个相邻的红色节点,也就是说红色节点不能有红色父或子节点。从给定节点到其以后的每条路径都包含相同数量的黑色节点。关于插入,它也有2个原则,1是插入的节点需要是红色,2是插入的位置需要是叶子节点。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
基于上面的原则,我们可以在上图看到,当我们想要加入h的时候,如果它是根节点,就直接用黑色即可;但它是叶子节点,所以是红色。插入后,如果父节点f是黑色,那就什么都不用做;但f是红色,所以就要把f改成黑色,除了父亲,往上的祖父节点也要跟着调整。从c出发的节点要包含相同数量的黑色节点,所以e的颜色也会相应调整为黑色。在下面例子中,我们看到除了换色,红黑树和AVL一样,有时也需要用到旋转。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
红黑树的好处是它的查询、插入、删除等操作的复杂度都比较稳定,可以控制在\(O(log2(n))\)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在树当中,有一种特殊的树的结构,就是堆(heap)。没错,前面讲到函数调用栈中堆栈这个概念的时候呢,是将堆作为内存存储空间介绍的。今天我们说的堆,是从一个数据结构的角度来看,那么堆是一个完全二叉树。那么完全二叉树和满二叉树有什么区别呢?满二叉树是除最后一层没有任何子节点以外,其它每一层的所有结点都有两个子结点二叉树。而完全二叉树指的是叶节点只能出现在最下层和次下层,并且最下面一层的结点都集中在该层最左边的若干位置的二叉树。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
堆的特点在于它所有结点的值必须“大于或等于”或“小于或等于”其子结点的值。它和其它的树型结构通过对象存储节点和指针的方式不太一样的地方在于,它可以用一个数组来存储。它可以被用于优先级队列,另外一个比较常见的应用就是堆排序。和快排和归并排序类似,它的时间复杂度也是\(O(nlog2(n))\)。不过有一点需要注意的是在堆排序的场景里,因为它对比交换(compare and swap)的次数可能要比快排多,所以虽然它们的时间复杂度一样,但是实际从性能上看,堆排序的性能会相对较低。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
字符串的匹配算法有哪些
|
||||||
|
|
||||||
|
说完了树,我们再看一下字符串。你可能问字符串和树有什么关系?其实,这里,我们就要说到另外一种特殊的树型的数据结构,字典树(trie)。但是说到trie前,我们可以先看看暴力(BF,Brute Force)、BM(Boyer-Moore)、RK(Rabin–Karp)和KMP(Knuth–Morris–Pratt) 这几种用于字符串的匹配的算法。首先我们可以对比下暴力和BM算法。在下面图中,我们可以看到将一组字符串和一个模式的对比。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
从上图中,我们可以看到如果使用暴力,也叫朴素(naive) 算法的话,在每一轮的迭代中,字母是一个一个移动的,直到匹配到为止,这种滚动匹配的方式一共要经历6个迭代。所以暴力算法虽然简单粗暴,但是效率却不高。而BM算法的话,可以用到图中坏字符(bad character) 这种方式,在字符串中看到和模版不匹配的坏字符就滑动跳过。除了坏字符,还有好后缀(good suffix) 的方式也可以达到类似的效果,它的原理就是在字符串中看到和模版匹配的好后缀就滑动对齐。这样通过跳过或对齐的滑动匹配的方式就能减少迭代的步骤。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
KMP和BM类似,但是匹配的顺序是倒过来的。在BM中,我们看到的是前面是坏字符,后面是好后缀。KMP看到的是前面是好前缀,后面是坏字符。在这里,只要找到了不匹配的,就知道下一个要从哪里开始再搜索了,避免了重新验证之前出现过的字符。
|
||||||
|
|
||||||
|
说完了BM和KMP,我们最后再来再看一下RK算法。这种方式的匹配方法是用一个字符串中的子串和模式串的哈希值做对比,如果哈希值是相等的,那就证明匹配上了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那么对比这几种字符串匹配的方式,它们的复杂度如何呢?如果我们定义字符串的长度是m,模式串的长度是n的话,那么下面就是它们各自的复杂度。我们可以看到暴力没有预处理的花费,但是处理过程本身的复杂度很高。BM在数据量比较大的时候会比较有优势,而KMP在数据量比较小的时候比较有优势。而和单个的字符串相比,RP在处理比较多的需要被匹配的字符串输入的时候,会更有优势。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
字符串的匹配算法有哪些
|
||||||
|
|
||||||
|
那我们经常说的路由,是用的哪一种的?它用的一种特殊的字典树(trie)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在这个过程中,如果搜索的字符串的长度是w的话,那么它的插入、搜索和删除的时间复杂度都是\(O(w)\);而如果m是输入的词的数量,n是最长的单词的话,那么它的空间复杂度是\(O(m\*n)\)。由于这种比较高的空间复杂度需求,所以字典树的结构最适合用来搜索具有相同的前缀的不同的字符串。但是不适合在某一个字符串中搜索某一个模式串。因此,这一点特别适用于路由中的查询。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那么字典树是如何实现的呢?这里面用到的方式是一个嵌套的对象。其中每一层都有子对象作为子节点。这里面需要考虑的是搜索、插入和删除这些场景的操作。搜索可以通过设置一个临时的当前变量来根据目前检查到的节点内容来更新。那么对于插入和删除来说呢,要注意的是在操作前确认之前是否已该节点已存在。如果说n是被查询的字符串的长度的话,那么无论是搜索还是插入或删除,这里的时间复杂度都是\(O(n)\),因为每一个字符都是要被检查的。如果插入的字符串的数量是m,n是最长的字符串的话,它的空间复杂度是\(O(m \* n)\)。
|
||||||
|
|
||||||
|
所以从这里我们可以看出,对比其它常见的字符串查询,字典树更适合的是用来处理具有同样前缀的多个字符串。而在一个字符串中匹配一个单独的模式串,这种方式就不适合了,因为这样就会占用大量的空间内存。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这一期,我们就讲到这里。那么通过图和树这两种数据结构呢,我们可以看出,它们最大的特点就是可以映射出现实世界中相对无序和未知的结构。从具象中看,我们可以发现它们可以用来解决web中的模块打包、路由等等。而抽象出来看,我们也可以看到算法所解决的问题经常是在混沌和未知中建立秩序,寻找出路。这对在杂乱的信息中梳理出秩序,或者我们在面临人生选择时,也是很有帮助的。同时,我们看到,在分析复杂度的问题时,我们思考的维度也要随之扩展。除了前几讲中提到的时间、空间和最好、最坏情况下的复杂度外,我们也要考虑预处理、不同应用场景下的复杂度。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
今天的思考题是,我们在讲到拓扑实现的时候,用了数组和unshift。你觉得这种方式是最高效的吗?从空间和时间复杂度或其它的角度的分析,这里有没有什么优化空间?
|
||||||
|
|
||||||
|
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
146
专栏/JavaScript进阶实战课/20算法思想:JS中分治、贪心、回溯和动态规划.md
Normal file
146
专栏/JavaScript进阶实战课/20算法思想:JS中分治、贪心、回溯和动态规划.md
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
20 算法思想:JS中分治、贪心、回溯和动态规划
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在算法中,我们提到如递归、分治、贪心、回溯和动态规划这些不同的算法思想时一般会分开来说。但实际上,它们之间是有着关联的。比如递归和回溯可以解决贪心顾及不到或者不能重试的问题。而动态规划又可以在利用递推公式的基础上解决递归中的一些短板。
|
||||||
|
|
||||||
|
能够比较好贯穿整个思想的是一个硬币找零的例子,即在几种不同面值硬币中,如何用最少的硬币数量来找零的问题。
|
||||||
|
|
||||||
|
贪心和递归分治
|
||||||
|
|
||||||
|
首先,我们来看下贪心算法如何解这个题。找零问题的核心是在几种不同面值如1、5、10分的硬币中,用最少的枚数凑出针一个需要找零的钱数。解决这个问题最简单的办法就是使用贪心(greedy)算法,它的核心逻辑是我们先选择面值较大的来找,再逐渐选小面额的。为什么这里是从大到小,而不是从小到大呢?因为通常面值越大,用到的数量就越少。
|
||||||
|
|
||||||
|
function minCoinChange(coins, amount) {
|
||||||
|
var change = [];
|
||||||
|
var total = 0;
|
||||||
|
for (let i = coins.length; i >= 0; i--) { // 从大到小循环
|
||||||
|
var coin = coins[i];
|
||||||
|
while (total + coin <= amount) { // 将硬币逐个加入,面值要小于商品价格
|
||||||
|
change.push(coin); // 将硬币加入到结果
|
||||||
|
total += coin; // 将硬币累加到总数
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
贪心是最简单的一种解决方案,可是这种方案有两个核心问题:
|
||||||
|
|
||||||
|
|
||||||
|
这种方式有时是得不到答案的,比如我们要找的钱数是17元,此时有面值为3元和5元的硬币。我们用了三张5元的后,还差2元要找,可这时我们只有3元,就没法找了。但如果我们先用3元,可以用四张3元加一张5元,就能得到17元。
|
||||||
|
这种方式得到的答案不一定是最优解。比如我们有面值为1、3、4元的硬币,需要找6元。如果先用4元,就要再加两张1元,这时硬币数一共三张。如果用3元的,只需要两张即可。
|
||||||
|
|
||||||
|
|
||||||
|
所以从运用贪心算法的例子里,我们可以看到,在有些情况下通过贪心算法可以得到局部最优解,可是没法保证全局的最优解,甚至有些时候都没有解。那么有什么更好的方式可以做到找零呢?另一种方式就是用到递归(recursion) 和 分治(divide and conquer) 了。我们先回到之前说过的斐波那契数列。我们看,用递归的方式来求解斐波那契中的例子,这个过程像什么呢?没错,它像是我们上一讲说到的树(tree) 形的数据结构,而且它的遍历过程就如同是一个深度优先(DFS)的遍历顺序。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
回溯和记忆函数
|
||||||
|
|
||||||
|
而我们所说的硬币找零的例子,和斐波那契相似,它也可以通过这样类似的一棵树来求解。这里我们也可以使用暴力递归的算法。这样,我们可以看到通过暴力枚举的朴素方式,就可以解决贪心带来的问题。但是这样也会带来新的问题,就是重叠子问题(overlapping subproblems)。假设我们有一个5元的商品,和面值为1和2的硬币。那么在找零的过程中,我们可以看到很多的重复计算。这个问题也很像我们之前在解斐波那契时遇到的去重问题。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那我们如何解决重叠子问题呢?拿找零的场景举例,我们可以将贪心和递归结合,通过减枝的方式来优化递归算法。前面我们说过贪心的核心问题之一是没办法回溯,而我们之前在讲到递归的时候,讲到过基于堆栈和栈帧的原理,先从贪心的方法开始尝试,如果成功,就继续执行。如果不成功,就回到上一步,换一种方法执行,这样就解决了类似的问题。而这里面,就用到了回溯(backtracking) 的思想。同样的,我们也可以通过记忆函数创建一个备忘录(memoization),来起到在执行中去重的作用。
|
||||||
|
|
||||||
|
递推和动态规划
|
||||||
|
|
||||||
|
那如果从上面的例子来看,这和我们之前讲的递归就没有太大不同了。那还有没有解决这个问题更高效的方法呢?下面我们再来看看动态规划(dynamic programming)。这里有两个重要的概念,第一个是无后效性,第二个是最优子结构。后无效性是说,如我们上图中所示,一个顶点下的子问题分支上的问题之间,依赖是单向的,后续的决策不会影响之前某个阶段的状态。而最优子结构指的是子问题之间是相互独立的,我们完全可以只基于子问题路径前面的状态推导出来,而不受其它路径状态的影响。这就好比我们在点外卖时,如果两个商品同时有优惠,那么要达到最大的优惠额度,我们就直接买两个打折后的商品就好了。但是假如折扣只能用于同一订单中的一个产品,那么我们可能就要选单价最高的商品打折。这时,我们的一个分支的选择会影响另外一个分支的选择,这种情况就不能算是最优子结构了。
|
||||||
|
|
||||||
|
我们看在找零问题中,没有以上影响。所以我们可以考虑动态优化。在动态规划中,解决问题用的是状态转移方程,其中包含了几个核心的元素,第一个就是初始化状态,它虽然叫初始状态,其实代表的是终止状态。在找零的例子里,我们希望最后找零的结果是0,所以,初始状态就是0。第二个核心元素就是状态参数,它指的是剩下需要找的零钱。我们每次在找零的过程中,就是这个状态在不断消减的过程,也就是最后它应该归零,达到初始状态。在上面的决策树中,我们看到每次选择的过程中,我们都要在一组集合中选中一种面值的硬币,这种选择,就是决策。
|
||||||
|
|
||||||
|
对于找零来说,我们可以写出状态转移方程$\(min(DP\[i\], DP(i-coin)+1)\)\(它的思考模式就是我们之前讲递归的一讲中提到**递推公式**时的思考模式,也就是说,我们自底而上的思考,如果我们决定用一枚硬币,那么就要加\)DP(i-coin)+1\(。如果我们不采用,那就是\)DP[i]\(。因为这是一个求最值的问题,所以我们通过\)min()$来对比两种决策中最小的值。
|
||||||
|
|
||||||
|
这时,你可能会有一个疑问,如果状态转移方程就是个递推公式,那为啥我们不直接用递归呢?动态规划和它有啥区别?这是因为如我们之前所讲,递归的特点是一递一归,先自上而下深度优先遍历的,这个过程是基于堆栈的原理实现的,就会产生额外的花费。而且从上到下的过程中,不能完全避免冗余的分支计算。所以为了解决这个问题,动态规划就派上用场了。它是通过两层嵌套的迭代循环,在内循环中,用到转移方程,自下而上的计算,解决了不必要的时间消耗。这样看,它可能不是那么函数式(声明式),而更命令式,但是它确实在执行效率上会更高。
|
||||||
|
|
||||||
|
function minCoinChange(coins, amount) {
|
||||||
|
var dp = Array(amount + 1).fill(Infinity); // 每种硬币需要多少
|
||||||
|
dp[0] = 0; // 找0元,需要0个硬币
|
||||||
|
for (let coin of coins) { // 循环每种硬币
|
||||||
|
for (let i = coin; i <= amount; i++) { // 遍历所有数量
|
||||||
|
dp[i] = Math.min(dp[i], dp[i - coin] + 1); // 更新最少需要用到的面值
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dp[amount] === Infinity ? -1 : dp[amount]; // 如果最后一个是无限的,没法找
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
所以如果我们追求的是效率的话,那么动态规划在这个例子中是最高的。
|
||||||
|
|
||||||
|
延伸:位运算符
|
||||||
|
|
||||||
|
讲完以上几种算法,我还想再延伸一点知识。在数据结构和算法中,除了动态规划外,还有哪些可以提高性能的算法思想呢?这里我们可以结合JavaScript的特性看看位运算(bitwise operation)的使用和思想。
|
||||||
|
|
||||||
|
在JavaScript中,整数都是32位的由0和1表示的二进制比特序列。举个例子,在位运算中,101就代表5可以用101来表示,9可以用1001来表示。
|
||||||
|
|
||||||
|
按位与(& AND)表示的是,对于每一个比特位,如果两个操作数相应的比特位都是1时,结果则为1,否则为0。 比如5&9的运算结果就是0001,也就是1。
|
||||||
|
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 // 5
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 // 9
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 // 5&9
|
||||||
|
|
||||||
|
|
||||||
|
那么以此类推,对于按位或(I OR)来说,针对每一个比特位,当两个操作数相应的比特位至少有一个1时,结果为1,否则为0。比如 5|9的运算结果就是1 1 0 1,也就是13。
|
||||||
|
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 // 5
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 // 9
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 1 // 5|9
|
||||||
|
|
||||||
|
|
||||||
|
那么对于按位非(~ NOT)来说,指的则是反转操作数的比特位,即0变成1,1变成0。那么~5的运算结果就是-6,~9的运算结果就是-10。
|
||||||
|
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 // 5
|
||||||
|
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 0 // -5
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 // 9
|
||||||
|
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 0 // -9
|
||||||
|
|
||||||
|
|
||||||
|
接下来我们再看看按位异或(^ XOR),它的操作是对于每一个比特位,当两个操作数相应的比特位有且只有一个1时,结果为1,否则为0。那么5^9的运算结果就是1 1 0 0,也就是12。
|
||||||
|
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 // 5
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 // 9
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 // 5^9
|
||||||
|
|
||||||
|
|
||||||
|
下面我们再来看看位移。这里又分为左移(<< Left shift),有符号右移(>> Right shift)和无符号右移(>>> Zero-fill right shift)。左移会将 a 的二进制形式向左移 b (< 32) 比特位,右边用0填充,所以9<>1的结果就是4。最后无符号右移会将 a 的二进制表示向右移 b (< 32) 位,丢弃被移出的位,并使用 0 在左侧填充,所以9>>1的结果就是2147483643。
|
||||||
|
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 // 9
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0 // 9 << 1
|
||||||
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 // 9 >> 1
|
||||||
|
0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 // 9 >>> 1
|
||||||
|
|
||||||
|
|
||||||
|
我们可以用一个列表来总结下相关的操作符,这样看起来更直观。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那么学会了位运算后,我们的加减乘除都可以通过位运算来计算。比如加法计算就可以用位运算来进行,这和我们小学时学的数学是一样的,我们在学10进制的加法时,就是在遇到10的时候,进一位。同样的方式,也可以用于位运算。这里同样可以用到递归或循环。下面,你可以参考一个递归的例子。
|
||||||
|
|
||||||
|
var add = function(a, b) {
|
||||||
|
if (b == 0) {
|
||||||
|
return a;
|
||||||
|
} else {
|
||||||
|
return add(a ^ b, (a & b) << 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
位运算从一定程度上可以让计算的效率更高,但是这样也会牺牲一定的可读性。所以在使用上,应该综合考虑。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这节课就到这里,最后我来做个小结。今天,我们看到了算法中的贪心、递归、分治、回溯和动态规划并不是完全独立的,它们在某种意义上是相连的。其实算法思想不光是用在程序中,如果我们仔细观察不同行业很成功的人和团队,多多少少都能发现他们具有算法思想。比如在当年解放战争时期,为什么解放军能赢,就是用到了类似分治和动态规划的思想。看似以少胜多,但每次通过分治,可以在局部以多胜少。同时通过动态规划防止局部成功来带的贪心思想,而是时刻关注着全局最优解,最后取得了全面的胜利。所以通过JS的原理,学好算法,不仅仅是纸上谈兵,而是可以在工作和生活的方方面面帮助我们“运筹帷幄之中,决胜千里之外”。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
今天的思考题是,关于找零的问题,除了贪心和动态规划的实现,你能不能也用文中提到的递归和回溯来动手实现下找零。
|
||||||
|
|
||||||
|
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
131
专栏/JavaScript进阶实战课/21创建型:为什么说Redux可以替代单例状态管理.md
Normal file
131
专栏/JavaScript进阶实战课/21创建型:为什么说Redux可以替代单例状态管理.md
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
21 创建型:为什么说Redux可以替代单例状态管理
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
时间过得真快,今天要开始专栏的第三个模块“JavaScript之术”了,前面我们已经了解了函数式和面向对象的编程模式、JavaScript 的数据结构与算法,从这节课开始,我们会展开讲解JavaScript用到的设计模式,结合一些三方的库,来帮助你理解和掌握如何通过设计模式进一步提高“生产力”,优化“生产关系”。
|
||||||
|
|
||||||
|
一般在介绍设计模式的时候,都会先说到创建型模式。创建型模式里又包含几种设计模式,最为大家熟知的,可能要数单例模式了,在很多语言中它经常被用来做状态管理。这节课,我们就来看看这种模式的优劣势,以及 Javascript 的 Redux 是怎么解决单例在状态管理中所面临的一些问题的。除了单例模式,我也会延伸讲解几个其他的创建型模式。下面我们还是先从单例模式本身说起吧。
|
||||||
|
|
||||||
|
单例模式
|
||||||
|
|
||||||
|
在ES6之前,单例模式在JavaScript中除了状态管理,其实更经常被用来做封装和命令空间。而在ES6以后,JS中已经加入了很多防止全局污染的手段。比如通过新增加的let和const两个关键字,将使用这两个关键字声明的变量保持在块范围内,创建全局变量已经很少见了。同时,JavaScript中的新加入的模块系统(modular system)使创建全局可访问的值更容易,而不会污染全局范围,这是因为它是从模块中导出值,并将这些值再导入其他文件中的。所以对于开发者来说,单例剩下的一个场景,就是状态管理了。
|
||||||
|
|
||||||
|
单例模式主要用于在应用程序中共享一个全局实例。这意味着使用同一个类创建两次对象的时候,第二次应该得到和第一次相同的对象。在JavaScript中,使用对象字面量创建一个对象本身就可以视作是单例的一个实现。当你创建一个新对象的时候,就已经是单例对象了。为什么这么说呢?因为在 JavaScript 中,对象永远不会相等,除非它们是同一个对象,因此即使我们创建两个具有完全相同属性的对象,它们也不会是相同的:
|
||||||
|
|
||||||
|
var obj1 = {
|
||||||
|
myprop: 'my value'
|
||||||
|
};
|
||||||
|
var obj2 = {
|
||||||
|
myprop: 'my value'
|
||||||
|
};
|
||||||
|
obj1 === obj2; // false
|
||||||
|
|
||||||
|
|
||||||
|
通过下面的例子,我们可以看到一个简单的单例实现。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
首先是创建一个全局的计数器,和一个包含加减方法的计数器。为了防止这个计数器被改动,我们可以采用freeze冻结对象的方法。最后,我们可以把计数器组件导出,之后如果有不同的模块导入同一个计数器组件并进行加减操作调用的话,得到的是基于同一个计数的加减结果。
|
||||||
|
|
||||||
|
var count = 0;
|
||||||
|
|
||||||
|
var counter = {
|
||||||
|
increment() {
|
||||||
|
return ++count;
|
||||||
|
},
|
||||||
|
decrement() {
|
||||||
|
return --count;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.freeze(counter);
|
||||||
|
export { counter };
|
||||||
|
|
||||||
|
|
||||||
|
单例模式下的状态管理
|
||||||
|
|
||||||
|
我们在讲到函数式编程模式的时候,说过它的一个重要思想就是要考虑副作用(side effect)。为什么单例式通常被认为是一个反模式,是因为基于副作用的考虑。
|
||||||
|
|
||||||
|
我们曾说过面向对象和函数式设计模式之间不是非此即彼的概念,而是可以互补的。单例的常见用例是在整个应用程序中拥有某种全局状态。这可能会导致代码库中依赖同一个可变对象的多个部分对它的修改产生冲突。因为通常,代码库的某些部分会修改全局状态中的值,而其他部分会使用该数据。执行顺序也是一个考量,我们不想在数据没进来的时候就不小心先消费数据,而导致异常。在相对复杂的程序中,随着组件数量的增长以及之间的相互依赖,不同的数据流之间的用全局状态管理会变得很难。
|
||||||
|
|
||||||
|
函数式编程的状态管理
|
||||||
|
|
||||||
|
那么面对以上的问题该怎么办呢?在 React 中,我们经常通过 Redux 或 React Context 等状态管理工具来管理全局状态,而不是使用单例对象。这里面就用了很多函数式编程的思想来替代单例模式来解决这些副作用。下面我们就来看看这种方案是怎么实现的。
|
||||||
|
|
||||||
|
虽然Redux 或 React Context 中也有全局状态,相关的行为可能看起来类似于单例,但这些工具提供了只读状态而不是单例的可变状态。比如在使用Redux时,只有纯函数reducer 才能在组件通过调度程序发送一个动作触发后的更新状态。使用这些工具不可能完全消除全局状态的缺点,但因为组件不能直接更新状态,这样做至少可以确保全局状态按照预期方式发生变化。
|
||||||
|
|
||||||
|
Redux的意图可以概括为三个原则:第一,全局的状态都在一个store里保存;第二,这个store里的状态对于整个应用来说都是只读的;第三,如果需要更新改变状态的话,则需要通过reducer来完成。
|
||||||
|
|
||||||
|
我们可以通过一个例子来看看。下图中UI界面有一个存款显示功能,显示目前的存款为0;界面上还有两个按钮分别带有存入和取出的功能。在开始的时候,当有人点击存入10元的动作时,一个存储的事件会传到事件处理器。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这个时候,事件处理器会对相关行为的数据打包为动作对象(action object),里面包含了动作类型字段,你可以将动作对象视为描述应用程序中发生的事件的对象。在这个例子中,类型就是“存入”,相关palyload的记录为“10”。动作对象打包好后会派发到存储器。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这个时候存储器会先调用已有的状态,也就是说当前的存款数量是0,加上10元,相加的合就是10元。reducer 是一个接收当前状态和行为对象的函数,如果需要,它会决定如何更新状态,并返回新状态(state, action) => newState。 你可以将 reducer 视为事件监听器,它根据接收到的操作(事件)类型处理事件。
|
||||||
|
|
||||||
|
reducer遵循一些特定规则,你也值得了解:第一,reducer只基于状态和行为动作这两个参数来计算新的状态值;第二,它必须遵循不可变原则,不能更改已有的状态,而只能拷贝状态并在拷贝的版本上做修改;第三,是reducer会避免任何副作用,比如异步操作。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
另外,Redux 存储有一个称为 dispatch 的方法。更新状态的唯一方法是调用 store.dispatch() 并传入一个动作对象。store 将运行 reducer 函数并将新的 state 值保存在里面,我们可以调用 getState() 来检索更新的状态值。
|
||||||
|
|
||||||
|
Redux 还有一个 Selector(选择器)知道如何从存储状态值中提取特定信息的函数。随着应用程序变得越来越大,这有助于避免重复逻辑,因为应用程序的不同部分需要读取相同的数据。最后,当存储器里的工作完成后,UI获得了更新后的10元状态值。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Redux 使用“单向数据流”应用程序结构。也就是说,第一,状态描述应用程序在某个时间点的状态,UI 会根据该状态呈现。第二,当应用程序发生某些事件变化时:比如 UI 的一个调度动作、store 运行 reducer、状态根据发生的事件情况更新、store通知UI状态已更改时,UI根据新状态重新渲染。这里我们可以看到,Redux的设计理念虽然不能说百分之百避免状态管理中的副作用,但是从很大程度上说,它要比单例模式更加有效的多。
|
||||||
|
|
||||||
|
工厂模式
|
||||||
|
|
||||||
|
除了单例,今天我还会介绍两个常见的创建型模式:工厂模式和原型模式。我们先来看看工厂模式,工厂模式,是使用工厂函数来创建对象的。它可以使我们调用工厂,而不是直接使用 new 运算符或 Object.create() 从类中创建新对象。在 JavaScript 中,工厂模式只不过是一个不使用 new 关键字就返回对象的函数。
|
||||||
|
|
||||||
|
工厂允许我们将对象的创建与其实现分开。本质上,工厂包装了新实例的创建,从而为我们提供了更多的灵活性和控制权。在工厂内部,我们可以选择使用 new 运算符创建类的新实例,或者利用闭包动态构建有状态的对象字面量,甚至可以根据特定条件返回不同的对象类型。工厂的消费者完全不知道如何创建实例。事实是,通过使用 new,我们将代码绑定到一种创建对象的特定方式,而使用工厂,我们可以拥有更大的灵活性。
|
||||||
|
|
||||||
|
它的优势是,如果我们要创建相对复杂和可配置的对象,工厂模式会很有用。对象中的键值取决于特定环境或配置的情况。使用工厂模式,我们可以轻松创建包含自定义键和值的新对象。当我们必须创建多个共享相同属性的较小对象时,工厂函数可以根据当前环境或用户特定的配置轻松返回自定义对象。相对的,它的劣势是它可能占用更多的内存。在相对没有那么复杂的情况下,每次创建新实例而不是新对象可能更节省空间。
|
||||||
|
|
||||||
|
工厂模式在JavaScript中有很多体现。比如 Object() 本身就像工厂。因为它根据输入创建不同的对象。如果你将数字传递给它,它可以在后台使用 Number() 构造函数创建一个对象。类似的还有字符串和布尔值。任何其他值,包括空值,都将创建一个普通对象。下面我们可以看一个例子。Object() 也是一个工厂这一事实并没有什么实际用途,只是通过这个例子,你能看到工厂模式在JavaScript中是无处不在。
|
||||||
|
|
||||||
|
var o = new Object(),
|
||||||
|
n = new Object(1),
|
||||||
|
s = Object('1'),
|
||||||
|
b = Object(true);
|
||||||
|
// test
|
||||||
|
o.constructor === Object; // true
|
||||||
|
n.constructor === Number; // true
|
||||||
|
s.constructor === String; // true
|
||||||
|
b.constructor === Boolean; // true
|
||||||
|
|
||||||
|
|
||||||
|
还有一个非常常见的例子就是我们说箭头函数(arrow function)就是工厂模式。之所以这么说是因为如果箭头函数体由单个表达式组成的话,在函数创建时会间接返回一个对象,所以是一个小型工厂函数。
|
||||||
|
|
||||||
|
var createUser = (userName) => ({ userName: userName });
|
||||||
|
createUser("bar"); // {userName: 'bar'}
|
||||||
|
createUser("foo"); // {userName: 'foo'}
|
||||||
|
|
||||||
|
|
||||||
|
原型模式
|
||||||
|
|
||||||
|
原型模式对于JavaScript来说不算陌生了,原型就是在许多相同类型的对象之间共享属性。
|
||||||
|
|
||||||
|
如果是从非JavaScript角度看,很多其他语言对原型模式的介绍中会使用到类(class)。而实际在JavaScript的情况下,原型继承可以避免用到类。这样做是利用它自身的优势,而不是试图模仿其他语言的特性。原型模式不仅让继承实现起来更简单,而且还可以提高性能,在对象中定义函数时,它们都是通过引用创建的(因此所有子对象都指向同一个函数),而不是创建单个副本。因为我们在前面的面向对象中已经用了很大篇幅来介绍原型模式,所以在这里我们就不做赘述了。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
最后,我们做个小结。今天我带你了解了几种不同创建型模式。希望你对这些模式有了不同于教科书说法的视角,比如我们看到在解决单例的问题上,针对前端的应用,如何因地制宜地解决相关的状态管理问题。JavaScript对状态管理并没有只是通过传统的单例和相关解决方案,而是另辟蹊径,通过函数式编程的思想,巧妙地利用了纯函数、不可变性等特点更好的解决了问题。所以我们说哪一种模式都不是死的,而是可以在具体的情况中灵活结合地应用。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
给你留个思考题,在说到单例模式时,虽然我们看到了Redux是如何解决状态管理问题的。但是JavaScript并不是只用在前端的语言,比如在Node,也有很多后端的用例使用这些创建型模式,你能分享下你的应用场景、遇到的相关问题,和一些解决方案吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
228
专栏/JavaScript进阶实战课/22结构型:Vue.js如何通过代理实现响应式编程.md
Normal file
228
专栏/JavaScript进阶实战课/22结构型:Vue.js如何通过代理实现响应式编程.md
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
22 结构型:Vue.js如何通过代理实现响应式编程
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
上一讲我们介绍了几种不同的创建型模式,今天我们来说说设计模式中的结构型模式。在结构型模式中,最经典、最常用的就非代理模式莫属了。在JavaScript的开发中,代理模式也是出现频率比较高的一种设计模式。
|
||||||
|
|
||||||
|
前端的朋友们应该都对Vue.js不会感到陌生。Vue.js的一个很大的特点就是用到了如今流行的响应式编程(Reactive Programming)。那它是怎么做到这一点的呢?这里面离不开代理模式,这一讲我们主要解答的就是这个问题。但是在解开谜底之前,我们先来看看代理模式比较传统直观的一些应用场景和实现方式吧。
|
||||||
|
|
||||||
|
代理的应用场景
|
||||||
|
|
||||||
|
在代理设计模式中,一个代理对象充当另一个主体对象的接口。很多人会把代理模式和门面模式做对比。后面几讲,我们会介绍到门面模式。这里我们需要了解的是代理模式与门面模式不同,门面模式最主要的功能是简化了接口的设计,把复杂的逻辑实现隐藏在背后,把不同的方法调用结合成更便捷的方法提供出来。而代理对象在调用者和主体对象之间,主要起到的作用是保护和控制调用者对主体对象的访问。代理会拦截所有或部分要在主体对象上执行的操作,有时会增强或补充它的行为。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
如上图所示,一般代理和主体具有相同的接口,这对调用者来说是透明的。代理将每个操作转发给主体,通过额外的预处理或后处理增强其行为。这种模式可能看起来像“二道贩子”,但存在即合理,代理特别是在性能优化方面还是起到了很大作用的。下面我们就一一来看看。
|
||||||
|
|
||||||
|
延迟初始化和缓存
|
||||||
|
|
||||||
|
因为代理充当了主体对象的保护作用,减少了客户端对代理背后真实主体无效的消耗。这就好像是公司里的销售,他们不是收到所有的客户需求都直接透传给到项目组来开发,而是接到客户的需求后,先给对方一个产品服务手册,当对方真的选择某项服务以及确定购买后,这时候售前才会转到项目团队来实施,再把结果交付给客户。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这个例子是我们可以称之为延迟初始化的应用。在这种情况下,代理可以作为接口提供帮助。代理接受初始化请求,在明确调用者确实会使用主体之前不会传递给它。客户端发出初始化请求并且代理先做响应,但实际上并没有将消息传递给主体对象,直到客户端明显需要主体完成一些工作,只有这样的情况下,代理才会将两条消息一起传递。
|
||||||
|
|
||||||
|
在这个基础之上,我们就可以看到第二个例子。在这个例子中,代理除了起到延迟初始化的作用外,还可以增加一层缓存。当客户端第一次访问的时候,代理会合并请求给主体,并且把结果先缓存,再分开返回给客户端。在客户端第二次发起请求2的时候,代理可以直接从缓存中读取信息,不需要访问主体,就直接返回给客户端。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
代理的其它应用场景
|
||||||
|
|
||||||
|
作为一门Web语言,JavaScript经常要和网络请求打交道,基于上述的方式,就能对性能优化起到很大的作用。除了延迟初始化和缓存外,代理还在很多其它方面有着很重要的用途。比如数据验证,代理可以在将输入转发给主体之前对输入的内容进行验证,确保无误后,再传给后端。除此之外,代理模式也可以用于安全验证,代理可以用来验证客户端是否被授权执行操作,只有在检查结果为肯定的情况下,才将请求发送给后端。
|
||||||
|
|
||||||
|
代理还有一个应用场景是日志记录,代理可以通过拦截方法调用和相关参数,重新编码。另外,它还可以获取远程对象并放到本地。说完了代理的应用场景,接下来我们可以看一下它在JavaScript中的实现方式。
|
||||||
|
|
||||||
|
代理的实现方式
|
||||||
|
|
||||||
|
代理模式在JavaScript中有很多种实现方式。其中包含了:1. 对象组合或对象字面量加工厂模式;2. 对象增强;3. 使用从ES6开始自带的内置的Proxy。这几种方式分别有它们的优劣势。
|
||||||
|
|
||||||
|
组合模式
|
||||||
|
|
||||||
|
我们先看看组合模式,在上一节我们讲过了,基于函数式编程的思想,我们在编程中,应该尽量保证主体的不变性。基于这个原则,组合可以被认为是创建代理的一种简单而安全的方法,因为它使主体保持不变,从而不会改变其原始行为。它唯一的缺点是我们必须手动 delegate 所有方法,即使我们只想代理其中的一个方法。此外,我们可能必须 delegate 对主题属性的访问。还有一点就是如果想要做到延迟初始化的话,基本只可以用到组合。下面我用伪代码做个展示:
|
||||||
|
|
||||||
|
class Calculator {
|
||||||
|
constructor () {
|
||||||
|
/*...*/
|
||||||
|
}
|
||||||
|
plus () { /*...*/ }
|
||||||
|
minus () { /*...*/ }
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProxyCalculator {
|
||||||
|
constructor (calculator) {
|
||||||
|
this.calculator = calculator
|
||||||
|
}
|
||||||
|
// 代理的方法
|
||||||
|
plus () { return this.calculator.divide() }
|
||||||
|
minus () { return this.calculator.multiply() }
|
||||||
|
}
|
||||||
|
|
||||||
|
var calculator = new Calculator();
|
||||||
|
var proxyCalculator = new ProxyCalculator(calculator);
|
||||||
|
|
||||||
|
|
||||||
|
除了上述的方式外,我们也可以基于组合的思路用工厂函数来做代理创建。
|
||||||
|
|
||||||
|
function factoryProxyCalculator (calculator) {
|
||||||
|
return {
|
||||||
|
// 代理的方法
|
||||||
|
plus () { return calculator.divide() },
|
||||||
|
minus () { return calculator.multiply() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var calculator = new Calculator();
|
||||||
|
var proxyCalculator = new factoryProxyCalculator(calculator);
|
||||||
|
|
||||||
|
|
||||||
|
对象增强
|
||||||
|
|
||||||
|
再来说第二种模式对象增强(Object Augmentation),对象增强还有一个名字叫猴子补丁(Monkey Patching)。对于对象增强来说,它的优点就是不需要 delegate 所有方法。但是它最大的问题是改变了主体对象。用这种方式确实是简化了代理创建的工作,但弊端是会造成函数式编程思想中的“副作用”,因为在这里,主体不再具有不可变性。
|
||||||
|
|
||||||
|
function patchingCalculator (calculator) {
|
||||||
|
var plusOrig = calculator.plus
|
||||||
|
calculator.plus = () => {
|
||||||
|
// 额外的逻辑
|
||||||
|
// 委托给主体
|
||||||
|
return plusOrig.apply(calculator)
|
||||||
|
}
|
||||||
|
return calculator
|
||||||
|
}
|
||||||
|
var calculator = new Calculator();
|
||||||
|
var safeCalculator = patchingCalculator(calculator);
|
||||||
|
|
||||||
|
|
||||||
|
内置Proxy
|
||||||
|
|
||||||
|
最后,我们再来看看使用ES6内置的Proxy。从ES6之后,JavaScript便支持了Proxy。它结合了对象组合和对象增强各自的优点,我们既不需要手动的去 delegate 所有的方法,也不会改变主体对象,保持了主体对象的不变性。但是它也有一个缺点,就是它几乎没有polyfill。也就是说,如果使用内置的代理,就要考虑在兼容性上做出一定的牺牲。真的是鱼和熊掌不能兼得。
|
||||||
|
|
||||||
|
var ProxyCalculatorHandler = {
|
||||||
|
get: (target, property) => {
|
||||||
|
if (property === 'plus') {
|
||||||
|
// 代理的方法
|
||||||
|
return function () {
|
||||||
|
// 额外的逻辑
|
||||||
|
// 委托给主体
|
||||||
|
return target.divide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 委托的方法和属性
|
||||||
|
return target[property]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var calculator = new Calculator();
|
||||||
|
var proxyCalculator = new Proxy(calculator, ProxyCalculatorHandler);
|
||||||
|
|
||||||
|
|
||||||
|
VUE如何用代理实现响应式编程
|
||||||
|
|
||||||
|
我们在上一讲讲到单例模式时,从解决状态管理时用到的Redux和reducer,可以看出一些三方库对传统面向对象的模式下,加入函数式编程来解决问题的思路。今天我们再来剖析下另外一个库,也就是Vue.js状态管理的思想。回到开篇的问题,Vue.js 是如何用代理实现响应式编程的呢?这里Vue.js通过代理创建了一种Change Obsverver的设计模式。
|
||||||
|
|
||||||
|
Vue.js 最显着的特点之一是无侵入的反应系统(unobtrusive reactivity system)。组件状态是响应式 JavaScript 对象,当被修改时,UI会更新。就像我们使用Excel时,如果我们在A2这个格子里设置了一个 A0 和 A1 相加的公式 “= A0 + A1”的话,当我们改动 A0 或 A1 的值的时候,A2也会随之变化。这也是我们在前面说过很多次的在函数式编程思想中的副作用(side effect)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在JavaScript中,如果我们用命令式编程的方式, 可以看到这种副作用是不存在的。
|
||||||
|
|
||||||
|
var A0 = 1;
|
||||||
|
var A1 = 2;
|
||||||
|
var A2 = A0 + A1;
|
||||||
|
console.log(A2) // 返回是 3
|
||||||
|
A0 = 2;
|
||||||
|
console.log(A2) // 返回仍然是 3
|
||||||
|
|
||||||
|
|
||||||
|
但响应式编程(Reactive Programming)是一种基于声明式编程的范式。如果要做到响应式编程,我们就会需要下面示例中这样一个 update 的更新功能。这个功能会使得每次当 A0 或 A1 发生变化时,更新 A2 的值。这样做,其实就产生了副作用,update 就是这个副作用。A0 和 A1 被称为这个副作用的依赖。这个副作用是依赖状态变化的订阅者。whenDepsChange 在这里是个伪代码的订阅功能。
|
||||||
|
|
||||||
|
var A2;
|
||||||
|
function update() {
|
||||||
|
A2 = A0 + A1;
|
||||||
|
}
|
||||||
|
whenDepsChange(update);
|
||||||
|
|
||||||
|
|
||||||
|
在 JavaScript 中没有 whenDepsChange 这样的机制可以跟踪局部变量的读取和写入 。Vue.js 能做的,是拦截对象属性的读写。JavaScript 中有两种拦截属性访问的方法:getter/setter 和 Proxies。由于浏览器支持限制,Vue 2 仅使用 getter/setter。在 Vue 3 中,Proxies 用于响应式对象,getter/setter 用于通过属性获取元素的 refs。下面是一些说明响应式对象如何工作的伪代码:
|
||||||
|
|
||||||
|
function reactive(obj) {
|
||||||
|
return new Proxy(obj, {
|
||||||
|
get(target, key) {
|
||||||
|
track(target, key)
|
||||||
|
return target[key]
|
||||||
|
},
|
||||||
|
set(target, key, value) {
|
||||||
|
target[key] = value
|
||||||
|
trigger(target, key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
你可能会想,上面对set和get的拦截和自定义,怎么就能做到对变化的观察呢?这就要说到 handler 里包含一系列具有预定义名称的可选方法了,称为陷阱方法(trap methods),例如:apply、get、set 和 has,在代理实例上执行相应操作时会自动调用这些方法。所以我们在拦截和自定义后,它们会在对象发生相关变化时被自动调用。所以假设我们可以订阅A0和A1的值的话,那么在这两个值有改动的情况下,就可以自动计算A2的更新。
|
||||||
|
|
||||||
|
import { reactive, computed } from 'vue'
|
||||||
|
var A0 = reactive(0);
|
||||||
|
var A1 = reactive(1);
|
||||||
|
var A2 = computed(() => A0.value + A1.value);
|
||||||
|
A0.value = 2;
|
||||||
|
|
||||||
|
|
||||||
|
延伸:Proxy还可以用于哪些场景
|
||||||
|
|
||||||
|
JavaScript内置的Proxy除了作为代理以外,还有很多作用。基于它的拦截和定制化的特点,Proxy也广泛用于对象虚拟化(object virtualization)、运算符重载(operator overloading)和最近很火的元编程(meta programming)。这里我们不用伪代码,换上一些简单的真代码,看看陷阱方法(trap methods)的强大之处。
|
||||||
|
|
||||||
|
对象虚拟化
|
||||||
|
|
||||||
|
我们先来看一下对象虚拟化,下面的例子中的 oddNumArr 单数数组就是一个虚拟的对象。我们可以查看一个单双数是不是在单数数组里,我们也可以获取一个单数,但是实际上这个数组里并没有储存任何数据。
|
||||||
|
|
||||||
|
const oddNumArr = new Proxy([], {
|
||||||
|
get: (target, index) => index % 2 === 1 ? index : Number(index)+1,
|
||||||
|
has: (target, number) => number % 2 === 1
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(4 in oddNumArr) // false
|
||||||
|
console.log(7 in oddNumArr) // true
|
||||||
|
console.log(oddNumArr[15]) // 15
|
||||||
|
console.log(oddNumArr[16]) // 17
|
||||||
|
|
||||||
|
|
||||||
|
运算符重载
|
||||||
|
|
||||||
|
运算符重载就是对已有的运算符重新进行定义,赋予其另一种功能,以适应不同的数据类型。比如在下面的例子中,我们就是通过重载“.”这个符号,所以在执行obj.count时,我们看到它同时返回了拦截get和set自定义的方法,以及返回了计数的结果。
|
||||||
|
|
||||||
|
var obj = new Proxy({}, {
|
||||||
|
get: function (target, key, receiver) {
|
||||||
|
console.log(`获取 ${key}!`);
|
||||||
|
return Reflect.get(target, key, receiver);
|
||||||
|
},
|
||||||
|
set: function (target, key, value, receiver) {
|
||||||
|
console.log(`设置 ${key}!`);
|
||||||
|
return Reflect.set(target, key, value, receiver);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
obj.count = 1; // 返回:设置 count!
|
||||||
|
obj.count;
|
||||||
|
// 返回:获取 count!
|
||||||
|
// 返回:设置 count!
|
||||||
|
// 返回:1
|
||||||
|
|
||||||
|
|
||||||
|
除了对象虚拟化和运算符重载 ,Proxy的强大之处还在于元编程的实现。但是元编程这个话题过大,我们后面会专门花一节课来讲。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过今天的内容,我们再一次看到,面向对象、函数式和响应式的交集。经典的关于设计模式的书对代理的解释更多是单纯的面向对象,但是通过开篇的问题,我们可以看到代理其实也是解决响应式编程的状态追踪问题的一把利器。所以,从Vue.js用到的基于代理的变化观察者(change observer)模式,我们也可以看出任何设计模式都不是绝对的,而是可以互相结合形成新的模式来解决问题。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
我们说响应式设计用到了很多函数式编程的思想,但是又不完全是函数式编程,你能说出它们的一些差别吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
320
专栏/JavaScript进阶实战课/23结构型:通过jQuery看结构型模式.md
Normal file
320
专栏/JavaScript进阶实战课/23结构型:通过jQuery看结构型模式.md
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
23 结构型:通过jQuery看结构型模式
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
今天,我带你顺着上一节的内容,再来看看在GoF(四人组)的《设计模式:可复用面向对象软件的基础》这本书中介绍的另外几种经典的结构型设计模式。我们可以通过jQuery来看看结构型的设计,说到这里,你可能会说jQuery可以算是被吐槽比较多的一个框架了,它有什么参考价值呢?但是我认为用户是用脚投票的,虽然很多人在骂着jQuery,但是同时也在用着。这也证明了它从开发上提供给人们的便捷,作为优点要大于它的缺点。其实,它的很多让人们“恨不释手”的设计背后都能看到结构型的设计模式。今天,我们就一起来看看吧。
|
||||||
|
|
||||||
|
几种经典的结构型模式
|
||||||
|
|
||||||
|
我们先来看看几种经典的结构型模式。分别是享元、门面和组合模式。
|
||||||
|
|
||||||
|
享元模式(flyweight)
|
||||||
|
|
||||||
|
享元模式(flyweight)的核心思想是通过减少对象的创建数量来节约内存。
|
||||||
|
|
||||||
|
享元模式最早是保罗·考尔德和马克·林顿在 1990 年提出的。喜欢看拳击的朋友可能知道享元的英文单词flyweight,其实是拳击里面的一个重量等级,叫做“特轻量”,也就是重量低于112磅的拳手。我们来看一个UFC比赛画面,一个重量级(heavy weight)和特轻量级 (flyweight)的选手放在一起对比,感受就更直观了。所以顾名思义,该模式旨在帮助我们实现轻量级的内存占用。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那么它是怎么减少对象的创建数量来节约内存的呢?这里的实现主要通过优化重复、缓慢且低效地共享数据的代码,与相关对象(例如应用程序配置、状态等)共享尽可能多的数据来做到最大限度地减少应用程序中内存的使用。在享元模式下,有3个核心概念:享元工厂、接口和具体享元。它们的关系如下图所示。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在享元模式中有两种数据 ,内在数据(intrinic)和外在数据(extrinsic)。 内在数据是在对象内部存储和调用的, 外部信息则是存储在外部的可被删除的数据。具有相同内在数据的对象可以由工厂方法创建单个共享对象。
|
||||||
|
|
||||||
|
享元接口(flyweight interface)可以接收和作用于外部状态。具体享元(concrete flyweight)是接口的实际实现,它负责存储可分享的内在状态,并且可以控制外部状态。享元工厂(flyweight factory)负责管理和创建享元对象。在客户端发起对享元对像的请求时,如果对象已经存在,就返回对象;如果尚未存在,则创建一个相关对象。
|
||||||
|
|
||||||
|
我们用一个租车的例子来解释,在租车行里,同一款车(car)可能有好几辆,这几款车的车型(model)、制造商(maker)、识别码(vin) 都是一样的,那我们可以说这些就是内在数据,可以通过单个共享对象 cars 存储。但是每个车目前出租的状态(availability)、租金 (sales) 是不同的,这些我们就可以作为外在数据存储。当通过 addCar 加进来新车时,createCar 可以判断是创建新的车型,还是基于vin返回已有的车型就可以了。所以下面我们虽然创建了5条车辆记录,但只有3个车型的实例。
|
||||||
|
|
||||||
|
// 储存车型的独立对象
|
||||||
|
class Car {
|
||||||
|
constructor(model, maker, vin) {
|
||||||
|
this.model = model;
|
||||||
|
this.maker = maker;
|
||||||
|
this.vin = vin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 储存具体车型对象的容器
|
||||||
|
var cars = new Map();
|
||||||
|
|
||||||
|
// 如果车型已知,就返回vin;未知就创建
|
||||||
|
var createCar = (model, maker, isbn) => {
|
||||||
|
var existingCar = cars.has(vin);
|
||||||
|
|
||||||
|
if (existingCar) {
|
||||||
|
return cars.get(vin);
|
||||||
|
}
|
||||||
|
|
||||||
|
var car = new Car(model, maker, vin);
|
||||||
|
cars.set(vin, car);
|
||||||
|
|
||||||
|
return car;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 存储租赁车的容器
|
||||||
|
var carList = [];
|
||||||
|
|
||||||
|
// 登记租赁车到列表
|
||||||
|
var addCar = (model, maker, vin, availability, sales) => {
|
||||||
|
var car = {
|
||||||
|
...createCar(model, maker, vin),
|
||||||
|
sales,
|
||||||
|
availability,
|
||||||
|
vin
|
||||||
|
};
|
||||||
|
|
||||||
|
carList.push(car);
|
||||||
|
return car;
|
||||||
|
};
|
||||||
|
|
||||||
|
addCar("911", "Porsche", "FR345", true, 2300);
|
||||||
|
addCar("911", "Porsche", "FR345", false, 2400);
|
||||||
|
addCar("Togun", "VW", "AZ567", false, 800);
|
||||||
|
addCar("C-Class", "Mercedes-Benz", "AS356", false, 1200);
|
||||||
|
addCar("C-Class", "Mercedes-Benz", "AS356", true, 1100);
|
||||||
|
|
||||||
|
|
||||||
|
随着硬件的发展,现在的内存RAM的大小基本都是GB级别的了,所以享元模式现如今已经不是那么重要了。但是如果创建的对象数量特别巨大,即使微小的差别也可能在规模化的过程中变得明显,因此这种设计模式我们依然需要关注。
|
||||||
|
|
||||||
|
上面我们举的例子是从数据角度的享元。下面,我们也可以从从另外一个事件处理的角度看看享元模式的应用。因为DOM(文档对象模型)是层层嵌套的,所以一个单一的,比如点击事件,可能会被多个DOM层级中的事件处理程序处理。DOM支持两种对象事件监听的方法,一种是自顶向下的事件捕获,一种是自底向上的事件冒泡。在事件捕获中,事件首先被最外层的元素捕获并传播到最内层的元素。 在事件冒泡中,事件被捕获并提供给最内部的元素,然后传播到外部元素。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在利用冒泡的场景里,基于它是自底向上的事件冒泡在最底层的元素,执行事件处理程序的,假设我们在文档中有许多相似的元素,当用户对它们执行操作的时候,比如点击或鼠标经过时,这些元素都会做出相似的行为反应。通常我们在构建菜单或其他基于列表的小部件时,所做的是将点击事件绑定到容器中的每个链接元素上,比如 $(‘ul li a’).on(…)。但如果用的是享元模式呢,我们可以将一个享元加到元素外部的容器上,自上而下地侦听来自下方的事件,然后根据需要使用相应逻辑来处理这些事件。而不是像冒泡那样,将点击绑定到多个元素。
|
||||||
|
|
||||||
|
下面的例子是用享元构建一个非常基本的accordion。在这里,jQuery用于将初始点击绑定到container div上,把许多独立的行为转化为共享的行为。
|
||||||
|
|
||||||
|
var stateManager = {
|
||||||
|
flyweight() {
|
||||||
|
var self = this;
|
||||||
|
$('#container')
|
||||||
|
.unbind()
|
||||||
|
.on('click', 'div.toggle', ({
|
||||||
|
target
|
||||||
|
}) => {
|
||||||
|
self.handleClick(target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
Facebook的詹姆斯·帕德奥尔西(James Padolsey)提出过另外一个jQuery中用到享元的概念。他说到在用jQuery的一些工具方法时,最好使用内部的jQuery.methodName底层方法,例如jQuery.text;而不是用对外暴露的jQuery.fn.methodName外部方法,例如jQuery.fn.text。jQuery.methodName是jQuery库本身在内部用来支持 jQuery.fn.methodName的底层方法。使用它,也就是等于在函数方法调用时,减少一层抽象或避免创建新的jQuery对象。因此詹姆斯提出了一个jQuery.single的想法,每次调用jQuery.single ,意味着多个对象的数据被整合到一个中心化共享的数据结构中,所以它也算是一种享元。
|
||||||
|
|
||||||
|
jQuery.single = (o => {
|
||||||
|
var collection = jQuery([1]);
|
||||||
|
return element => {
|
||||||
|
// Give collection the element:
|
||||||
|
collection[0] = element;
|
||||||
|
// Return the collection:
|
||||||
|
return collection;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
$('div').on('click', function() {
|
||||||
|
var html = jQuery
|
||||||
|
.single(this)
|
||||||
|
.next()
|
||||||
|
.html();
|
||||||
|
console.log(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
门面模式(facade)
|
||||||
|
|
||||||
|
门面模式(facade)是一种经常可以在jQuery等JavaScript库中看到的结构,它的特点是把很多的解决复杂的兼容性问题的实现隐藏在背后,只通过“门面”将对外的接口抽象提供给使用者。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
打个比方,我们平时用的搜索引擎可以说是“门面”,它的界面和操作简单的不能再简单。但是背后的实现逻辑是非常复杂的,这里牵扯到了调度、网络信息的爬取、解析、索引等等,最后呈现出来的才是搜索。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
同样的,比如我们常用的jQuery的 $() 查询器做的就是把很多复杂的用来接收和解析多种类型的查询功能在后端通过Sizzle引擎处理,呈现给开发者的是一套更加简便的选择器。
|
||||||
|
|
||||||
|
下面我们可以看一个 $(document).ready(…) 的例子,在背后,它是基于一个bindReady的函数来实现的。
|
||||||
|
|
||||||
|
function bindReady() {
|
||||||
|
// ...
|
||||||
|
if (document.addEventListener) {
|
||||||
|
// Use the handy event callback
|
||||||
|
document.addEventListener('DOMContentLoaded', DOMContentLoaded, false);
|
||||||
|
// A fallback to window.onload, that will always work
|
||||||
|
window.addEventListener('load', jQuery.ready, false);
|
||||||
|
// If IE event model is used
|
||||||
|
} else if (document.attachEvent) {
|
||||||
|
document.attachEvent('onreadystatechange', DOMContentLoaded);
|
||||||
|
// A fallback to window.onload, that will always work
|
||||||
|
window.attachEvent('onload', jQuery.ready);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
门面模式对于jQuery的使用者来说提供了很多方便,但这也不是没有代价的。它虽然降低了开发成本,但在一定程度上牺牲了性能。对于一些简单的页面开发,很多开发者还是会选择使用它,原因呢就是因为这些应用中页面开发的要求远不到工业级,但是通过jQuery能节省的开发成本确是指数级的,这也从一个侧面体现了为什么jQuery还这么流行的原因。所以在开发的时候,我们除了要关注设计模式能带来什么好处以外,更要注意使用的场景,在开发效率和性能之间做出平衡。
|
||||||
|
|
||||||
|
组合模式(composite)
|
||||||
|
|
||||||
|
组合模式(composite)指的是可以通过同一种方式处理单个或一组对象。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在jQuery中,可以用统一的方式处理单个元素以及一个元素的合集,因为它们返回的都是一个 jQuery对象。下面的选择器的代码示例演示了这一点。在这里,可以为单个元素,比如具有唯一ID的元素,或具有相同标签名称元素类型或类属性的一组元素的两个选择添加同一个展示类类的属性。
|
||||||
|
|
||||||
|
// 单个元素
|
||||||
|
$( "#specialNote" ).addClass( "show" );
|
||||||
|
$( "#mainContainer" ).addClass( "show" );
|
||||||
|
// 一组元素
|
||||||
|
$( "div" ).addClass( "show" );
|
||||||
|
$( ".item" ).addClass( "show" );
|
||||||
|
|
||||||
|
|
||||||
|
延伸:什么是包装器模式
|
||||||
|
|
||||||
|
在设计模式中,我们经常提到重构(refactoring),与之相对的就是包装(wrapper)。在设计模式中,装饰器(decorator)和适配器(adaptor)通常是起到包装的作用。装饰器和适配器的特点是它们都是在不改变原始对象的情况下做相关的装饰和适配。比如我们在用一个第三方的库或者接口的时候,是没办法修改人家的代码的,不然就很可能引起副作用。这个时候包装就起到了曲线救国的作用。我们说装饰器和适配器都是包装器,那它们有什么区别呢?下面我们就来看看这两种模式分别是如何做到包装的吧。
|
||||||
|
|
||||||
|
装饰器(decorator)
|
||||||
|
|
||||||
|
举个例子,我们看到现在大街上的帅哥美女比前几年多了。这里面可能有几个原因,一个是化妆,一个是整容。化妆在这里面就是一个典型的装饰器模式。因为我们的面容是很难改变的,如果整容,就需要伤筋动骨,可能还会有毁容的风险,所以化妆就可以避免这种风险。化的不好可以随时卸妆重画,我们也看到网上有很多很牛的化妆师可以画出各种不同的明星脸,简直和换脸术无二。这样每天都可以换一张脸,想想也是很酷的。化妆就是对对象的包装,当我们不想直接修改一个组件时,装饰器在这时就派上了用场。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
如果我们从一个非自研的、不想,或不能直接操作的组件中提取类,那么就可以用到装饰器。而且,装饰器可以让我们的程序中减少大量的子类。装饰器模式可以提供方便的一个特点呢,就是对预期行为的定制和配置。下面我们可以看看它的一个比较基础的实现。在这个例子中,我们给一个车装饰成了限量升级版。
|
||||||
|
|
||||||
|
class Car {
|
||||||
|
constructor(model, maker, price) {
|
||||||
|
this.model = model;
|
||||||
|
this.maker = maker;
|
||||||
|
this.price = price;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDetails() {
|
||||||
|
return `${this.model} by ${this.maker}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decorator 1
|
||||||
|
function specialEdition(car) {
|
||||||
|
car.isSpecial = false;
|
||||||
|
car.specialEdition = function() {
|
||||||
|
return `special edition ${car.getDetails()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return car;
|
||||||
|
}
|
||||||
|
|
||||||
|
// decorator 2
|
||||||
|
function upgrade(car) {
|
||||||
|
car.isUpgraded = true;
|
||||||
|
car.price += 5000;
|
||||||
|
return car;
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage
|
||||||
|
var car1 = specialEdition(new Car('Camry', 'Toyota', 10000));
|
||||||
|
|
||||||
|
console.log(car1.isSpecial); // false
|
||||||
|
console.log(car1.specialEdition()); // 'special edition Camry by Toyota'
|
||||||
|
|
||||||
|
var car2 = upgrade(new Car('Crown', 'Toyota', 15000));
|
||||||
|
|
||||||
|
console.log(car2.isUpgraded); // true
|
||||||
|
console.log(car2.price); // 20000
|
||||||
|
|
||||||
|
|
||||||
|
在 jQuery当中,装饰者可以用extend() 来实现。比如在下面例子中,假设我们有一个车载OS系统,具有默认的选项和一些功能选项,我们可以通过extend的方式,把它们加进去。并且在这个过程中,不改变defaults和options的对象本身。
|
||||||
|
|
||||||
|
// define the objects we're going to use
|
||||||
|
vehicleOS = {
|
||||||
|
defaults: {},
|
||||||
|
options: {},
|
||||||
|
settings: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// merge defaults and options, without modifying defaults explicitly
|
||||||
|
vehicleOS.settings = $.extend(
|
||||||
|
{},
|
||||||
|
decoratorApp.defaults,
|
||||||
|
decoratorApp.options
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
适配器(adaptor)
|
||||||
|
|
||||||
|
适配器也不难理解,这个在我们生活中太常见了。比如我们购买了一个英标的电子产品,如果在国内使用,是找不到合适的插座的。因为标准不同,孔型也不一样,就无法插入。但是如果我们使用一个转换接头,这个问题就迎刃而解了。类似的例子还有很多,比如Type-C和Type-A接头之间的转换也是同样的道理。下面我们就来看看适配器的原理和实现。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
适配器的例子在jQuery中也是无处不见,比如CSS中关于透明度的get和set,只需要通过以下方式就可以使用了,看起来是不是很方便呢:
|
||||||
|
|
||||||
|
// Cross browser opacity:
|
||||||
|
// opacity: 0.9; Chrome 4+, FF2+, Saf3.1+, Opera 9+, IE9, iOS 3.2+, Android 2.1+
|
||||||
|
// filter: alpha(opacity=90); IE6-IE8
|
||||||
|
// Setting opacity
|
||||||
|
$( ".container" ).css( { opacity: .5 } );
|
||||||
|
// Getting opacity
|
||||||
|
var currentOpacity = $( ".container" ).css('opacity');
|
||||||
|
|
||||||
|
|
||||||
|
但其实在背后,jQuery做了很多的工作。
|
||||||
|
|
||||||
|
get: function( elem, computed ) {
|
||||||
|
// IE uses filters for opacity
|
||||||
|
return ropacity.test( (
|
||||||
|
computed && elem.currentStyle ?
|
||||||
|
elem.currentStyle.filter : elem.style.filter) || "" ) ?
|
||||||
|
( parseFloat( RegExp.$1 ) / 100 ) + "" :
|
||||||
|
computed ? "1" : "";
|
||||||
|
},
|
||||||
|
set: function( elem, value ) {
|
||||||
|
var style = elem.style,
|
||||||
|
currentStyle = elem.currentStyle,
|
||||||
|
opacity = jQuery.isNumeric( value ) ?
|
||||||
|
"alpha(opacity=" + value * 100 + ")" : "",
|
||||||
|
filter = currentStyle && currentStyle.filter || style.filter || "";
|
||||||
|
// IE has trouble with opacity if it does not have layout
|
||||||
|
// Force it by setting the zoom level
|
||||||
|
style.zoom = 1;
|
||||||
|
// if setting opacity to 1, and no other filters
|
||||||
|
//exist - attempt to remove filter attribute #6652
|
||||||
|
if ( value >= 1 && jQuery.trim( filter.replace( ralpha, "" ) ) === "" ) {
|
||||||
|
// Setting style.filter to null, "" & " " still leave
|
||||||
|
// "filter:" in the cssText if "filter:" is present at all,
|
||||||
|
// clearType is disabled, we want to avoid this style.removeAttribute
|
||||||
|
// is IE Only, but so apparently is this code path...
|
||||||
|
style.removeAttribute( "filter" );
|
||||||
|
// if there there is no filter style applied in a css rule, we are done
|
||||||
|
if ( currentStyle && !currentStyle.filter ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// otherwise, set new filter values
|
||||||
|
style.filter = ralpha.test( filter ) ?
|
||||||
|
filter.replace( ralpha, opacity ) :
|
||||||
|
filter + " " + opacity;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
装饰器还是适配器?
|
||||||
|
|
||||||
|
对比两者,我们可以看出,它们之间的相同性在于它们都是在无法直接改变主体对象的情况下,加了一层包装。而区别在于不同使用场景的包装方式不同,装饰器更多是通过包装嵌套添加一些不同的特征,适配器的包装更多是一个对象和另一个对象之间的接口映射。
|
||||||
|
|
||||||
|
另外,我们什么时候应该避免使用装饰器和适配器呢?如果我们可以控制整个实现(也就是说我们拥有自己的库)的话,就可以通过更改基本代码而不是通过包装使接口变得复杂化来实现相同的实用程序了。此外,与任何设计模式一样,如果非要使用包装模式的话,要确保实际的结果和影响比原始的、无模式的代码更简单且更易于理解。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这节课先到这里,我来做个小结。今天我带你通过jQuery来了解了几种比较经典的结构型设计模式。我们看出虽然jQuery被很多人吐槽,但是存在即合理。它确实通过门面模式、组合模式和适配器模式为开发提供了很多的便利。同时它也有着自己独特的享元模式的应用场景,并且也有着自己对装饰器的支持。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
我们前面说到装饰器,也可以用我们上一讲提到的Proxy来实现,你能说说它是怎么实现的吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
269
专栏/JavaScript进阶实战课/24行为型:通过观察者、迭代器模式看JS异步回调.md
Normal file
269
专栏/JavaScript进阶实战课/24行为型:通过观察者、迭代器模式看JS异步回调.md
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
24 行为型:通过观察者、迭代器模式看JS异步回调
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
说完了创建和结构型的设计模式,这一讲,我们来学习行为型的设计模式。我们前面说前端编程是事件驱动(event driven)的,之所以这么说,是因为前端编程几乎离不开用户和应用的交互,通常我们的程序会根据用户的屏幕点击或者页面滑动操作,而做出相应的反应。这一点从我们前面讲到的React和Vue的例子中也可以发现,响应式编程(reactive programming)的思想对前端应用设计有着很强的影响。
|
||||||
|
|
||||||
|
今天我们会讲到行为型设计模式中的观察者模式,它是事件驱动在设计层面上的体现。通过这一讲的内容,你也可以更了解JS开发中事件驱动和异步的特性。
|
||||||
|
|
||||||
|
事件驱动
|
||||||
|
|
||||||
|
说到事件驱动,就离不开两个主要的对象,一个是被观察对象change observable,一个是观察者observer。被观察对象会因为事件而发生改变,而观察者则会被这个改变驱动,做出一些反应。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
我们可以通过上图一个简单的例子来了解下这种模式。假设我们有两个观察者1和2,它们的初始值分别是11和21。observable是被观察对象,这个时候如果被观察对象做出了增加1的行为,观察者1和2的值就会更新为12和22。下面我们可以看看它的实现,通常一个被观察者的实现是模版式的;而观察者则是根据不同的反应需求,来设计不同的逻辑。
|
||||||
|
|
||||||
|
class Observable {
|
||||||
|
constructor() {
|
||||||
|
this.observers = [];
|
||||||
|
}
|
||||||
|
subscribe(func) {
|
||||||
|
this.observers.push(func);
|
||||||
|
}
|
||||||
|
unsubscribe(func) {
|
||||||
|
this.observers = this.observers.filter(observer => observer !== func);
|
||||||
|
}
|
||||||
|
notify(change) {
|
||||||
|
this.observers.forEach(observer => {observer.update(change);});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Observer {
|
||||||
|
constructor(state) {
|
||||||
|
this.state = state;
|
||||||
|
this.initState = state;
|
||||||
|
}
|
||||||
|
update(change) {
|
||||||
|
let state = this.state;
|
||||||
|
switch (change) {
|
||||||
|
case 'increase':
|
||||||
|
this.state = ++state;
|
||||||
|
break;
|
||||||
|
case 'decrease':
|
||||||
|
this.state = --state;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.state = this.initState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用
|
||||||
|
var observable = new Observable();
|
||||||
|
|
||||||
|
var observer1 = new Observer(11);
|
||||||
|
var observer2 = new Observer(21);
|
||||||
|
|
||||||
|
observable.subscribe(observer1);
|
||||||
|
observable.subscribe(observer2);
|
||||||
|
|
||||||
|
observable.notify('increase');
|
||||||
|
|
||||||
|
console.log(observer1.state); // 12
|
||||||
|
console.log(observer2.state); // 22
|
||||||
|
|
||||||
|
|
||||||
|
在这个事件驱动的案例里,用到的就是观察者(observer)模式。观察者模式是行为型模式当中的一种,并且算是出镜率最高的、被谈及最多的一种模式了,它是事件驱动在设计层面的体现。事件驱动最常见的就是UI事件了,比如有时我们需要程序根据监听触屏或滑动行为做出反应。除了UI事件驱动,还有两个事件驱动的场景使用频率非常高,就是网络和后端事件。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
我们先说网络事件,这是观察者模式使用频率非常高的一个场景,原因是我们现在大多的应用都是通过XHR这种模式,动态加载内容并且展示于前端的,通常会等待客户端请求通过网络到达服务器端,得到返回的状态,然后再执行任何操作。这就需要我们通过观察者模式来订阅不同的状态,并且根据状态做出不同的行为反应。
|
||||||
|
|
||||||
|
另外一个场景是后端事件,比如在Node.js当中,观察者模式也是非常重要的甚至可以说是最核心的模式之一,以至于被内置的EventEmmiter功能所支持。举个例子,Node 中的“fs”模块是一个用于处理文件和目录的API。我们可以把一个文件当做一个对象,那么当它被打开、读取或关闭的时候,其实就是不同的状态事件,在这个过程中,如果要针对这些不同的事件做通知和处理,就会用到观察者模式。
|
||||||
|
|
||||||
|
事件驱动和异步
|
||||||
|
|
||||||
|
我们前面说了观察者模式通常和事件驱动相关,那它和异步又有什么关系呢?
|
||||||
|
|
||||||
|
这个关系不难理解,一些计算机程序,例如科学模拟和机器学习模型,是受计算约束的,它们连续运行,没有停顿,直到计算出结果,这种是同步编程。然而,大多数现实世界的计算机程序都是异步的,也就是说这些程序经常不得不在等待数据到达或某些事件发生时停止计算。Web 浏览器中的JavaScript程序通常是事件驱动的,这意味着它们在实际执行任何操作之前会等待用户点击。或者在网络事件或后端事件中,也是要等待某个状态或动作才能开启程序运行。
|
||||||
|
|
||||||
|
所以回到上面的问题,观察者模式和异步的关系在于:事件就是基于异步产生的,而我们需要通过观察对基于异步产生的事件来做出反应。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
JavaScript提供了一系列支持异步观察者模式的功能,分别是callback、promise/then、generator/next 和 aync/await。下面,让我们分别来看看这几种模式吧。
|
||||||
|
|
||||||
|
Callback模式
|
||||||
|
|
||||||
|
在 JavaScript 中,回调模式(callback pattern) 就是我们在一个函数操作完时把结果作为参数传递给另外一个函数的这样一个操作。在函数式编程中,这种传递结果的方式称为连续传递样式(CPS,continous passing style)。它表示的是调用函数不直接返回结果,而是通过回调传递结果。作为一个通用的概念,CPS不代表一定是异步操作,它也可以是同步操作。下面,我们可以针对同步和异步分别来看一下。
|
||||||
|
|
||||||
|
同步CPS
|
||||||
|
|
||||||
|
我们先来看看同步的CPS。下面的这个加法函数你应该很容易理解,我们把a和b的值相加,然后返回结果。这种方式叫做直接样式(direct style)。
|
||||||
|
|
||||||
|
function add (a, b) {
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
那如果用callback模式来做同步CPS会是怎样呢。在这个例子里,syncCPS不直接返回结果,而是通过callback来返回 a 加 b 的结果。
|
||||||
|
|
||||||
|
function syncCPS (a, b, callback) {
|
||||||
|
callback(a + b);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('同步之前');
|
||||||
|
syncCPS(1, 2, result => console.log(`结果: ${result}`));
|
||||||
|
console.log('同步之后');
|
||||||
|
|
||||||
|
// 同步之前
|
||||||
|
// 结果: 3
|
||||||
|
// 同步之后
|
||||||
|
|
||||||
|
|
||||||
|
异步CPS
|
||||||
|
|
||||||
|
下面我们再看看异步的CPS。这里最经典的例子就是setTimeout了,通过示例代码,你可以看到,同样的,异步CPS不直接返回结果,而是通过callback来返回a加b的结果。但是在这里,我们通过setTimeout让这个结果是在0.1秒后再返回,这里我们可以看到在执行到setTimeout时,它没有在等待结果,而是返回给asyncCPS,执行下一个console.log(‘异步之后’)的任务。
|
||||||
|
|
||||||
|
function asyncCPS (a, b, callback) {
|
||||||
|
setTimeout(() => callback(a + b), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('异步之前');
|
||||||
|
asyncCPS(1, 2, result => console.log(`结果: ${result}`))
|
||||||
|
console.log('异步之后');
|
||||||
|
|
||||||
|
// 异步之前
|
||||||
|
// 异步之后
|
||||||
|
// 结果: 3
|
||||||
|
|
||||||
|
|
||||||
|
在上面的例子中,其函数调用和控制流转顺序可以用下图表示:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
你可能会有疑问,就是在同步CPS的例子中,这种方式有没有意义呢?答案是没有。因为我们上面只是举个例子来看同步CPS是可以实现的,但其实如果函数是同步的,根本没有用CPS的必要。使用直接样式而不是同步CPS来实现同步接口始终是更加合理的实践。
|
||||||
|
|
||||||
|
回调地狱
|
||||||
|
|
||||||
|
在ES6之前,我们几乎只能通过callback来做异步回调。举个例子,在下面的例子中,我们想获取宝可梦的machineInfo机器数据,可以通过网上一个公开的库基于XMLHttpRequest来获取。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
需要基于这样一个链条 pockmon=>moveInfo=>machineInfo。
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var API_BASE_URL = 'https://pokeapi.co/api/v2';
|
||||||
|
var pokemonInfo = null;
|
||||||
|
var moveInfo = null;
|
||||||
|
var machineInfo = null;
|
||||||
|
|
||||||
|
var pokemonXHR = new XMLHttpRequest();
|
||||||
|
pokemonXHR.open('GET', `${API_BASE_URL}/pokemon/1`);
|
||||||
|
pokemonXHR.send();
|
||||||
|
|
||||||
|
pokemonXHR.onload = function () {
|
||||||
|
pokemonInfo = this.response
|
||||||
|
var moveXHR = new XMLHttpRequest();
|
||||||
|
moveXHR.open('GET', pokemonInfo.moves[0].move.url);
|
||||||
|
moveXHR.send();
|
||||||
|
|
||||||
|
moveXHR.onload = function () {
|
||||||
|
moveInfo = this.response;
|
||||||
|
var machineXHR = new XMLHttpRequest();
|
||||||
|
machineXHR.open('GET', moveInfo.machines[0].machine.url);
|
||||||
|
machineXHR.send();
|
||||||
|
|
||||||
|
machineXHR.onload = function () { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
你可以看到,在这个例子里,我们每要获取下一级的接口数据,都要重新建立一个新的HTTP请求,而且这些回调函数都是一层套一层的。如果是一个大型项目的话,这么多层的嵌套是很不好的代码结构,这种多级的异步嵌套调用的问题也被叫做“回调地狱(callback hell)”,是使用callback来做异步回调时要面临的难题。 这个问题怎么解呢?下面我们就来看看promise和async的出现是如何解决这个问题的。
|
||||||
|
|
||||||
|
ES6+的异步模式
|
||||||
|
|
||||||
|
自从ES6开始,JavaScript中就逐步引入了很多硬核的工具来帮助处理异步事件。从一开始的Promise,到生成器(Generator)和迭代器(Iterator),再到后来的async/await。回调地狱的问题被一步步解决了,让异步处理重见阳光。下面,我们从Promise开始,看看这个问题是怎么一步步被解决的。
|
||||||
|
|
||||||
|
Promises
|
||||||
|
|
||||||
|
自从ES6之后,JavaScript就引入了一系列新的内置工具来帮助处理异步事件。其中最开始的是promise和then. 我们可以用then的连接方式,在每次fetch之后都调用一个then来进行下一层的操作。我们来看这段代码,这里减少了很多XMLHttpRequest的代码,但是仍然没有脱离一层层的调用。所以这种代码也不优雅。
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var API_BASE_URL = 'https://pokeapi.co/api/v2';
|
||||||
|
var pokemonInfo = null;
|
||||||
|
var moveInfo = null;
|
||||||
|
var machineInfo = null;
|
||||||
|
|
||||||
|
var showResults = () => {
|
||||||
|
console.log('Pokemon', pokemonInfo);
|
||||||
|
console.log('Move', moveInfo);
|
||||||
|
console.log('Machine', machineInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(`${API_BASE_URL}/pokemon/1`)
|
||||||
|
.then((response) => {
|
||||||
|
pokemonInfo = response;
|
||||||
|
fetch(pokemonInfo.moves[0].move.url)
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
moveInfo = response;
|
||||||
|
fetch(moveInfo.machines[0].machine.url)
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
machineInfo = response;
|
||||||
|
showResults();
|
||||||
|
})
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
生成器和迭代器
|
||||||
|
|
||||||
|
那么怎么才能像同步的方式一样来执行异步的调用呢?在ES6版本中,在引入Promise和then的同时,也引入了生成器(Generator)和迭代器(Interator)的概念。生成器是可以让函数中一行代码执行完后通过yield先暂停,然后执行外部代码,等外部代码执行中出现next时,再返回函数内部执行下一条语句。是不是很神奇!这个例子中的next其实就是行为型模式中的迭代器模式的一种体现。
|
||||||
|
|
||||||
|
function* getResult() {
|
||||||
|
var pokemonInfo = yield fetch(`${API_BASE_URL}/pokemon/1`);
|
||||||
|
var moveInfo = yield fetch(pokemonInfo.moves[0].move.url);
|
||||||
|
var machineInfo = yield fetch(moveInfo.machines[0].machine.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = showResults();
|
||||||
|
|
||||||
|
result.next().value.then((response) => {
|
||||||
|
return result.next(response).value
|
||||||
|
}).then((response) => {
|
||||||
|
return result.next(response).value
|
||||||
|
}).then((response) => {
|
||||||
|
return result.next(response).value
|
||||||
|
|
||||||
|
|
||||||
|
async/await
|
||||||
|
|
||||||
|
但是使用next也有问题,就是这样的回调链条也会非常的长。意识到了promise/then的问题后,在ES8的版本中,JavaScript又引入了async/await的概念。这样,每一次获取信息的异步操作如pokemonInfo、moveInfo等都可以独立通过await来进行,写法上又可以保持和同步类似的简洁性。下面,我们来看看:
|
||||||
|
|
||||||
|
async function showResults() {
|
||||||
|
try {
|
||||||
|
var pokemonInfo = await fetch(`${API_BASE_URL}/pokemon/1`)
|
||||||
|
console.log(pokemonInfo)
|
||||||
|
var moveInfo = await fetch(pokemonInfo.moves[0].move.url)
|
||||||
|
console.log(moveInfo)
|
||||||
|
var machineInfo = await fetch(moveInfo.machines[0].machine.url)
|
||||||
|
console.log(machineInfo)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showResults();
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
今天,我带你通过观察者和迭代器等模式了解了异步编程的设计模式。可以说事件驱动、响应式编程、异步编程包含了JavaScript中很大一部分设计模式的核心概念。所以这篇文章虽然篇幅不大,但是确实是了解和应用JavaScript的核心非常重要的内容。
|
||||||
|
|
||||||
|
当然,异步编程是一个很大的话题,我们今天一次也讲不完,后面我们还会用一讲内容继续来看异步中的并行和串行开发,并且在讲到多线程的时候,我们会更深入理解异步的实现逻辑。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
最后给你留一道思考题,前面我们说CPS就是回调,那反之,我们可以说回调就是CPS吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
340
专栏/JavaScript进阶实战课/25行为型:模版、策略和状态模式有什么区别?.md
Normal file
340
专栏/JavaScript进阶实战课/25行为型:模版、策略和状态模式有什么区别?.md
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
25 行为型:模版、策略和状态模式有什么区别?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
今天我们来说说设计模式中剩下的几种行为型模式。我个人觉得剩下这六种模式可以大致分为两类,一类是偏向“策略模型”的设计模式,这里包含了策略、状态和模版这三种模式。另外一大类是偏向“数据传递”的设计模式,这里就包含了中介、命令和职责链这几种模式。这些类别的模式,有什么共同和不同呢?我们先从它们各自的思想和实现来看看。
|
||||||
|
|
||||||
|
策略模型类的行为模式
|
||||||
|
|
||||||
|
首先,我们来看看策略、状态和模版这三种偏向“策略模型”的设计模式吧。
|
||||||
|
|
||||||
|
策略模式
|
||||||
|
|
||||||
|
先说策略模式(strategy),它的核心思想是在运行时基于场景选择策略。
|
||||||
|
|
||||||
|
我们可以举一个例子,我们的汽车轮胎适配就算是一种策略模式,比如在冰天雪地的西伯利亚,可以选择冬季轮胎;如果是平时用的买菜车,就选择普通轮胎;如果是去草原狂奔,就换上越野轮胎。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
下面,我们可以通过一个红绿灯程序来看一下这一概念的实现。在这个例子中,我们可以看到 交通控制(TrafficControl)就决定了运行时环境的上下文,它可以通过转换( turn )这个方法来切换不同的策略。红绿灯(TrafficLight)是一个抽象类的策略,它可以根据环境需要,延伸出具体类的策略。
|
||||||
|
|
||||||
|
// encapsulation
|
||||||
|
class TrafficControl {
|
||||||
|
turn(trafficlight) {
|
||||||
|
return trafficlight.trafficcolor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TrafficLight {
|
||||||
|
trafficcolor() {
|
||||||
|
return this.colorDesc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// strategy 1
|
||||||
|
class RedLight extends TrafficLight {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.colorDesc = "Stop";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// strategy 2
|
||||||
|
class YellowLight extends TrafficLight {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.colorDesc = "Wait";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// strategy 3
|
||||||
|
class GreenLight extends TrafficLight {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.colorDesc = "Go";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage
|
||||||
|
var trafficControl = new TrafficControl();
|
||||||
|
|
||||||
|
console.log(trafficControl.turn(new RedLight())); // Stop
|
||||||
|
console.log(trafficControl.turn(new YellowLight())); // Wait
|
||||||
|
console.log(trafficControl.turn(new GreenLight())); // Go
|
||||||
|
|
||||||
|
|
||||||
|
状态模式
|
||||||
|
|
||||||
|
下面我们再来看看状态模式(state),它的核心概念是根据运行时状态的不同,切换不同的策略。所以我们可以说它是策略模式的一个延伸。
|
||||||
|
|
||||||
|
这里,我们可以拿酒店预定举个例子,比如我们都有在一些文旅类门户网站上预定酒店的经验。在预定的时候,通常有几种不同的状态,比如当我们下单支付前,订单状态可能是“未确认”,这时我们可以确认或删除,但是因为还没有预定成功,所以没有取消的选项。但是当我们已确认并完成支付,就没有再次确认或删除的动作了,这时,我们只能选择取消。再然后,一般很多酒店都规定只能在入住前24小时选择取消,而如果在临近入住的24小时之内,那么在这个区间内连取消的按钮可能都失效了。这时,我们只能选择入住或和客服沟通取消。这就是状态模式,也就是说程序依据不同运行时状态,做不同的策略反应。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
同样,我们可以通过讲策略模式时的红绿灯案例做一些改造,加入状态 state,看看会发生什么。这里,我们可以看到每次当我们执行turn在做切换的时候,随着状态在红、黄、绿三种状态之间循环更新,红绿灯的指示也跟着更新。
|
||||||
|
|
||||||
|
class TrafficControl {
|
||||||
|
constructor() {
|
||||||
|
this.states = [new GreenLight(), new RedLight(), new YellowLight()];
|
||||||
|
this.current = this.states[0];
|
||||||
|
}
|
||||||
|
turn() {
|
||||||
|
const totalStates = this.states.length;
|
||||||
|
let currentIndex = this.states.findIndex(light => light === this.current);
|
||||||
|
if (currentIndex + 1 < totalStates) this.current = this.states[currentIndex + 1];
|
||||||
|
else this.current = this.states[0];
|
||||||
|
}
|
||||||
|
desc() {
|
||||||
|
return this.current.desc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TrafficLight {
|
||||||
|
constructor(light) {
|
||||||
|
this.light = light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RedLight extends TrafficLight {
|
||||||
|
constructor() {
|
||||||
|
super('red');
|
||||||
|
}
|
||||||
|
desc() {
|
||||||
|
return 'Stop';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class YellowLight extends TrafficLight {
|
||||||
|
constructor() {
|
||||||
|
super('yellow');
|
||||||
|
}
|
||||||
|
desc() {
|
||||||
|
return 'Wait';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GreenLight extends TrafficLight {
|
||||||
|
constructor() {
|
||||||
|
super('green');
|
||||||
|
}
|
||||||
|
desc() {
|
||||||
|
return 'Go';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage
|
||||||
|
var trafficControl = new TrafficControl();
|
||||||
|
console.log(trafficControl.desc()); // 'Go'
|
||||||
|
trafficControl.turn();
|
||||||
|
console.log(trafficControl.desc()); // 'Stop'
|
||||||
|
trafficControl.turn();
|
||||||
|
console.log(trafficControl.desc()); // 'Wait'
|
||||||
|
|
||||||
|
|
||||||
|
模版模式
|
||||||
|
|
||||||
|
最后,我们再来看看模版模式(template)。它的核心思想是在一个方法中定义一个业务逻辑模版,并将某些步骤推迟到子类中实现。所以它和策略模式有些类似。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
下面我们可以看一个实现的例子。在这个例子里,我们看到员工employee里的工作work就是一个模版,它里面的任务tasks是延迟到开发developer和设计designer两个子类中去实现的。这就是一个简单的模版模式的设计实现。
|
||||||
|
|
||||||
|
class Employee {
|
||||||
|
constructor(name, salary) {
|
||||||
|
this.name = name;
|
||||||
|
this.salary = salary;
|
||||||
|
}
|
||||||
|
work() {
|
||||||
|
return `${this.name}负责${this.tasks()}`;
|
||||||
|
}
|
||||||
|
getPaid() {
|
||||||
|
return `${this.name}薪资是${this.salary}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Developer extends Employee {
|
||||||
|
constructor(name, salary) {
|
||||||
|
super(name, salary);
|
||||||
|
}
|
||||||
|
// 细节由子类实现
|
||||||
|
tasks() {
|
||||||
|
return '写代码';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Designer extends Employee {
|
||||||
|
constructor(name, salary) {
|
||||||
|
super(name, salary);
|
||||||
|
}
|
||||||
|
// 细节由子类实现
|
||||||
|
tasks() {
|
||||||
|
return '做设计';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage
|
||||||
|
var dev = new Developer('张三', 10000);
|
||||||
|
console.log(dev.getPaid()); // '张三薪资是10000'
|
||||||
|
console.log(dev.work()); // '张三负责写代码'
|
||||||
|
var designer = new Designer('李四', 11000);
|
||||||
|
console.log(designer.getPaid()); // '李四薪资是11000'
|
||||||
|
console.log(designer.work()); // '李四负责做设计'
|
||||||
|
|
||||||
|
|
||||||
|
这里我先做个阶段性小结,从上面的例子中,我们可以看出,无论是策略、状态还是模版模式,它们都是基于某种“策略模型”来实现的。比如策略模式中的策略是基于上行文来切换;在状态模式中是根据状态来做切换;而最后在模版模式的例子中,某些策略模版在父类中定义,有些则在子类中实现。
|
||||||
|
|
||||||
|
信息传递类的行为模式
|
||||||
|
|
||||||
|
中介模式
|
||||||
|
|
||||||
|
中介者(mediator)模式的核心是使组件可以通过一个中心点相互交互。现实生活中,航空地面塔台就是一个例子,我们不可能让飞机之间交谈,而是通过地面控制台协调。地面塔台人员需要确保所有飞机都接收到安全飞行所需的信息,而不会撞到其他飞机。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
我们还是通过一段代码,来看看这种模式的实现。塔台(TrafficTower)有着接收每架飞机坐标和获取某架飞机坐标方法。同时,飞机会登记自己的坐标和获取其它飞机的坐标。这些信息都是统一由塔台(TrafficTower)来管理的。
|
||||||
|
|
||||||
|
class TrafficTower {
|
||||||
|
#airplanes;
|
||||||
|
constructor() {
|
||||||
|
this.#airplanes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
register(airplane) {
|
||||||
|
this.#airplanes.push(airplane);
|
||||||
|
airplane.register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestCoordinates(airplane) {
|
||||||
|
return this.#airplanes.filter(plane => airplane !== plane).map(plane => plane.coordinates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Airplane {
|
||||||
|
constructor(coordinates) {
|
||||||
|
this.coordinates = coordinates;
|
||||||
|
this.trafficTower = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
register(trafficTower) {
|
||||||
|
this.trafficTower = trafficTower;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestCoordinates() {
|
||||||
|
if (this.trafficTower) return this.trafficTower.requestCoordinates(this);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage
|
||||||
|
var tower = new TrafficTower();
|
||||||
|
|
||||||
|
var airplanes = [new Airplane(10), new Airplane(20), new Airplane(30)];
|
||||||
|
airplanes.forEach(airplane => {
|
||||||
|
tower.register(airplane);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(airplanes.map(airplane => airplane.requestCoordinates()))
|
||||||
|
// [[20, 30], [10, 30], [10, 20]]
|
||||||
|
|
||||||
|
|
||||||
|
命令模式
|
||||||
|
|
||||||
|
说完中介模式,我们再来看看命令模式,命令模式(command)允许我们将命令和发起命令操作的对象分离,这么做的好处是对于处理具有特定生命周期或者列队执行的命令,它会给我们更多的控制权。并且它还提供了将方法调用作为传参的能力,这样做的好处是可以让方法按需执行。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
下面我们可以看看这种模式的样例。事务管理者OperationManager 接到了执行任务,会根据不同的命令,如启动行动(StartOperationCommand)、追踪行动状态(TrackOperationCommand)及取消行动CancelOperationCommand 等来执行。
|
||||||
|
|
||||||
|
class OperationManager {
|
||||||
|
constructor() {
|
||||||
|
this.operations = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(command, ...args) {
|
||||||
|
return command.execute(this.operations, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Command {
|
||||||
|
constructor(execute) {
|
||||||
|
this.execute = execute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StartOperationCommand(operation, id) {
|
||||||
|
return new Command(operations => {
|
||||||
|
operations.push(id);
|
||||||
|
console.log(`你成功的启动了${operation}行动,代号${id}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function CancelOperationCommand(id) {
|
||||||
|
return new Command(operations => {
|
||||||
|
operations = operations.filter(operation => operation.id !== id);
|
||||||
|
console.log(`你取消了行动代号${id}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackOperationCommand(id) {
|
||||||
|
return new Command(() =>
|
||||||
|
console.log(`你的行动代号${id},目前正在执行中`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manager = new OperationManager();
|
||||||
|
|
||||||
|
manager.execute(new StartOperationCommand("猎豹", "318"));
|
||||||
|
// 返回:你成功的启动了猎豹行动,代号318
|
||||||
|
manager.execute(new TrackOperationCommand("318"));
|
||||||
|
// 返回:你的行动代号318,目前正在执行中
|
||||||
|
manager.execute(new CancelOperationCommand("318"));
|
||||||
|
// 返回:你取消了行动代号318
|
||||||
|
|
||||||
|
|
||||||
|
命令模式可以在许多不同的情况下使用,特别是在创建重交互的UI上,比如编辑器里撤消的操作,因为它可以让UI对象和行为操作做到高度解耦。这种模式也可以用来代替回调函数,这也是因为它更支持模块化地将行为操作在对象之间传递。
|
||||||
|
|
||||||
|
职责链模式
|
||||||
|
|
||||||
|
最后,再来看下职责链模式,职责链模式(chain of responsibility)核心是将请求的发送者和接收者解耦。它的实现是通过一个对象链,链中的每个对象都可以处理请求或将其传递给下一个对象。其实在我们前面讲享元时,就提到过事件捕获和冒泡,JavaScript 内部就是用这个方式来处理事件捕获和冒泡的。同样在享元例子中,我们也提到过,jQuery 是通过职责链每次返回一个对象来做到的链接式调用。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那么这种职责链是如何实现的呢?其实它的实现并不复杂,通过下面的例子我们可以看一下。你也可以很容易实现一个简化版的链式累加。我们通过累加(CumulativeSum)中的加法(add)可以循环上一个对象的结果和参数相加后的结果,作为返回值传给下一个方法。
|
||||||
|
|
||||||
|
class CumulativeSum {
|
||||||
|
constructor(intialValue = 0) {
|
||||||
|
this.sum = intialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(value) {
|
||||||
|
this.sum += value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usage
|
||||||
|
var sum = new CumulativeSum();
|
||||||
|
console.log(sum.add(10).add(2).add(50).sum); // 62
|
||||||
|
|
||||||
|
|
||||||
|
通过上面的三种模式的例子,我们都可以看到数据在不同对象中的传递。中介模式中,我们需要在网状的环境中,信息对多个对象中通过中介进行传输;命令模式中,我们看到了信息在对象和对象之间的传输;而最后,在职责链的模式中,我们又看到了信息在一个流水线中的传输。因此我说它们是偏向“数据传递”的设计模式。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
今天,我带你看了几种不同的行为型设计模式。到现在为止,我们所有的经典模式就都讲完了。
|
||||||
|
|
||||||
|
这一讲我们看的这些模式除了在JavaScript中会用到以外,在多数其它语言中也都适用,所以算是比较脱离语言本身的几种“普世”模式了。在之后的一讲中,我们会再次看几种在JavaScript中特有的一些设计模式。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
如果你用过 Redux 的话,应该用过它的开发者工具中的时间旅行式调试,它可以将应用程序的状态向前、向后或移动到任意时间点。你知道这个功能的实现用到了今天学到的哪(些)种行为型设计模式吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
300
专栏/JavaScript进阶实战课/26特殊型:前端有哪些处理加载和渲染的特殊“模式”?.md
Normal file
300
专栏/JavaScript进阶实战课/26特殊型:前端有哪些处理加载和渲染的特殊“模式”?.md
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
26 特殊型:前端有哪些处理加载和渲染的特殊“模式”?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在之前的几讲中,我们介绍完了经典的设计模式,今天我们来看看在JS中特有的一些设计模式。其实从函数式编程开始,我们就一直强调了前端所重视的响应式编程思想,所以我认为这个部分可以分三大块儿来系统地看下响应式编程在JS中的设计模式,分别是组件化、加载渲染和性能优化模式。下面,我们就来深入地了解下。
|
||||||
|
|
||||||
|
组件化的模式
|
||||||
|
|
||||||
|
首先,我们来看下组件化的设计模式。我想请你先思考这样一个问题:为什么说组件化在前端,特别是基于JS开发的React框架中,有着非常重要的位置呢?
|
||||||
|
|
||||||
|
随着Web和WebGL的逐渐普及,我们之前用的很多桌面应用都被网页版的应用替代,一是它可以达到和桌面应用类似的功能,二是这样节省了资源在我们的手机或是PC上的下载和存储,三是因为这样可以让我们随时随地访问我们需要的内容,只要有网络,输入一个URL便可以使用。而且在办公类的软件中它也大大增加了工作的协同,比如我们常用的QQ邮箱、Google Docs或石墨文档都可以看做是由组件组成的复杂的Web应用。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
接下来我们就来讲讲几种在React中会用到的组件化的模式。这里我们首先需要注意的是,在React中的组件化和我们通常了解的Web Component是有区别的。我们在说到Web Component的时候,更多关注的是组件的封装和重用,也就是经典的面向对象的设计模式思想。而React Component更多关注的是,通过声明式的方式更好地让DOM和状态数据之间同步。为了更好地实现组件化,React推出了一些不同的模式,这里比较重要的就包含了上下文提供者、渲染属性、高阶组件和后来出现的Hooks。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这里,我们可以大体将这些组件化的模式分为两类,一类是在Hooks出现之前的上下文提供者、渲染属性、高阶组件模式,一类是Hooks出现后带来的新模式。下面,就让我们从经典模式依次来看下。
|
||||||
|
|
||||||
|
经典模式
|
||||||
|
|
||||||
|
首先我们先来看上下文提供者模式(Context Provider Pattern),这是一种通过创建上下文将数据传给多个组件的组件化方式。它的作用是可以避免prop-drilling,也就是避免将数据从父组件逐层下传到子组件的繁琐过程。
|
||||||
|
|
||||||
|
那么这种模式有什么实际的应用呢?那就是当我们想根据应用的界面目前所处的上下文来做展示的时候,这种模式就派上用场了。比如我们需要对于登录和未登录态的用户展现不同的内容,或者是当春节或国庆等特殊节日的主题皮肤发放时,又或是根据用户所在的国家或地区做展示调整的时候,都可以用到提供者模式。
|
||||||
|
|
||||||
|
举个例子,假如我们有一个菜单,里面包含了一个列表和列表元素。通过以下代码,我们看到如果将数据一层层传递,就会变得非常繁琐。
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const data = { ... }
|
||||||
|
return (<Menu data={data} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
var Menu = ({ data }) => <List data={data} />
|
||||||
|
var List = ({ data }) => <ListItem data={data} />
|
||||||
|
var ListItem = ({ data }) => <span>{data.listItem}</span>
|
||||||
|
|
||||||
|
|
||||||
|
而通过React.createContext,我们创建一个主题。之后通过ThemeContext.Provider,我们可以创建一个相关的上下文。这样我们无需将数据一一传递给每个菜单里的元素,便可以让上下文中的元素都可以获取相关的数据。
|
||||||
|
|
||||||
|
var ThemeContext = React.createContext();
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
var data = {};
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value = {data}>
|
||||||
|
<Menu />
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
通过React.useContext,可以获取元素上下文中的数据来进行读写。
|
||||||
|
|
||||||
|
function useThemeContext() {
|
||||||
|
var theme = useContext(ThemeContext);
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListItem() {
|
||||||
|
var theme = useThemeContext();
|
||||||
|
return <li style={theme.theme}>...</li>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
说完了上下文提供者,下面我们再来看看渲染属性模式(Render Props Pattern)。先思考一个问题,我们为什么需要渲染属性模式呢?
|
||||||
|
|
||||||
|
下面我们可以看看,在没有渲染模式的情况下,可能会出现的问题。比如在下面的价格计算器的例子中,我们想让程序根据输入的产品购买数量计算出价格。但是,在没有渲染属性的情况下,我们虽然想通过value * 188计算根据输入的购买的数量展示计算的价格,但是价格计算拿不到输入的购买数量的value值,所以实际上计算不出价格。
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
<h1>价格计算器</h1>
|
||||||
|
<Input />
|
||||||
|
<Amount />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Input() {
|
||||||
|
var [value, setValue] = useState("");
|
||||||
|
return (
|
||||||
|
<input type="text"
|
||||||
|
value={value}
|
||||||
|
placeholder="输入数量"
|
||||||
|
onChange={e => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Amount({ value = 0 }) {
|
||||||
|
return <div className="amount">{value * 188}元</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
为了解决这个问题,我们就可以用到render props,把amount作为input的子元素,在其中传入value参数。也就是说通过渲染属性,我们可以在不同的组件之间通过属性来共享某些数据或逻辑。
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
...
|
||||||
|
<Input>
|
||||||
|
{value => (
|
||||||
|
<>
|
||||||
|
<Amount value={value} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Input>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Input() {
|
||||||
|
...
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input ... />
|
||||||
|
{props.children(value)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Amount({ value = 0 }) {
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
说完了渲染属性,下面我们再来看看高阶组件模式(HOC,Higher Order Components Pattern),这种叫法听上去像是我们在说到函数式编程时提到的高阶函数。当时我们说过,当我们把一个函数作为参数传入,并且返回一个函数作为结果的函数,叫做高阶函数。那么在高阶组件中,它的概念类似,就是我们可以把一个组件作为参数传入,并且返回一个组件。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那么它有什么应用呢?假设在没有高阶组件的情况下,我们想给一些按钮或文字组件增加一个圆边,可能要修改组件内的代码,而通过高阶函数,我们可以在原始的直角的文字框和按钮组件的基础上面包装一些方法来得到圆边的效果。在实际的应用中,它可以起到类似于“装饰器”的作用。它不仅让我们不需要对组件本身做修改,而且还可以让我们重复使用抽象出来的功能,避免代码冗余。
|
||||||
|
|
||||||
|
// 高阶函数
|
||||||
|
var enhancedFunction = higherOrderFunction(originalFunction);
|
||||||
|
// 高阶组件
|
||||||
|
var enhancedComponent = higherOrderComponent(originalComponent);
|
||||||
|
|
||||||
|
// 高阶组件作为装饰器
|
||||||
|
var RoundedText = withRoundCorners(Text);
|
||||||
|
var RoundedButton = withRoundCorners(Button);
|
||||||
|
|
||||||
|
|
||||||
|
Hooks模式
|
||||||
|
|
||||||
|
前面,我们看完了几种经典的可以帮助我们实现和优化组件化的方式,下面,我们再来看看从React 16.8开始新增的Hooks。
|
||||||
|
|
||||||
|
Hooks最直接的作用是可以用函数来代替ES6引入的新的class创建组件。如我们之前在讲到JavaScript中面向对象时所提到的,关于this的绑定理解,对于从其他语言转来的开发者来说还是比较绕脑的。而通过Hooks,可以通过函数表达更直观地创建组件。
|
||||||
|
|
||||||
|
我们先来看一个计数器的例子。如果用传统class的方式来创建的话,需要用到 this.state,this.setState 和 this.state.count 来初始化,设置和读取计数状态。
|
||||||
|
|
||||||
|
class App extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { count: 0 };
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
|
||||||
|
点击了{this.state.count}次。
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
而如果用Hooks的话,我们可以通过 useState(0) 来代替 this.state 创建一个初始化设置为0的计数状态。同时,再点击计数按钮的时候,我们可以用 count 和 setCount 分别来代替 this.state.count 和 this.setState 做计数状态的读写。
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
function APP() {
|
||||||
|
var [count, setCount] = useState(0);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setCount(count + 1)}>点击了{count}次。
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
在这个例子中,你可以看到,我们刚才是通过用解构(destructure)的方式,创建了两个计数状态的变量,一个是 count,另外一个是 setCount。这样当我们将这两个值赋值给 userState(0) 的时候,它们会分别被赋值为获取计数和更新计数。
|
||||||
|
|
||||||
|
// 数组解构
|
||||||
|
var [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
// 上等同于
|
||||||
|
var countStateVariable = useState(0);
|
||||||
|
var count = countStateVariable[0];
|
||||||
|
var setCount = countStateVariable[1];
|
||||||
|
|
||||||
|
|
||||||
|
除了用函数代替类以外,Hooks另外的一个作用是可以让组件按功能解耦、再按相关性组合的功能。比如在没有Hooks的情况下,我们可能需要通过组件的生命周期来组合功能,我们的一个应用组件会有两个不同的功能,一个是显示购物车商品数量,一个是显示现有的客服状态。如果我们用的是同一个组件的生命周期 componentDidMount 管理,那就会将不相干的功能聚合在了一起,而通过useEffect这样的一个Hook,就可以把不相干的功能拆开,再根据相关性聚合在一起。
|
||||||
|
|
||||||
|
class App extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
this.state = { count: 0, isAvailable: null };
|
||||||
|
this.handleChange = this.handleChange.bind(this);
|
||||||
|
}
|
||||||
|
componentDidMount() {
|
||||||
|
document.title = `目前购物车中有${this.state.count}件商品`;
|
||||||
|
UserSupport.subscribeToChange(this.props.staff.id, this.handleChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function App(props) {
|
||||||
|
var [count, setCount] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `目前购物车中有${count}件商品`;
|
||||||
|
});
|
||||||
|
var [isOnline, setIsOnline] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
function handleChange(status) {setIsOnline(status.isAvailable);}
|
||||||
|
UserSupport.subscribeToChange(props.staff.id, handleChange);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
除了上述的两个好处以外,Hooks还可以让逻辑在组件之间更容易共享。我们在上面的例子中看到过,之前如果我们想要让一些行为重用在不同的组件上,可能要通过渲染属性和高阶组件来完成,但这样做的成本是要以改变了组件原有的结构作为代价的,组件外被包裹了大量的提供者、渲染属性和高阶函数等,这种过度的抽象化就造成了包装地狱(wrapper hell)。而有了Hooks之后,它可以通过更原生的方式替代类似的工作。
|
||||||
|
|
||||||
|
加载渲染模式
|
||||||
|
|
||||||
|
之前我们在讲到响应式设计的时候,有讲过前端渲染(CSR)、后端渲染(SSR)和水合(hydration)的概念。今天,就让我们更系统地了解下加载渲染模式吧。首先,我们先从渲染模式说起。
|
||||||
|
|
||||||
|
渲染模式
|
||||||
|
|
||||||
|
在Web应用刚刚流行的时候,很多的单页应用(SPA)都是通过前端渲染开发的,这样的模式不需要浏览器的刷新就可以让用户在不同的页面间进行切换。这样在带来了方便的同时,也会造成性能上问题,比如它的FCP(First Contentful Paint,首次内容绘制时间)、 LCP(Largest Contentful Paint,最大内容绘制时间)、TTI(Time to Interactive,首次可交互时间) 会比较长,遇到这种情况,通过初始最小化代码、预加载、懒加载、代码切割和缓存等手段,性能上的问题可以得到一些解决。
|
||||||
|
|
||||||
|
但是相比后端渲染,前端渲染除了性能上的问题,还会造成SEO的问题。通常为了解决SEO的问题,一些网站会在SPA的基础上再专门生成一套供搜索引擎检索的后端页面。但是作为搜索的入口页面,后端渲染的页面也会被访问到,它最大的问题就是到第一字节的时间(TTFB)会比较长。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
为了解决前端和后端渲染的问题,静态渲染(static rendering)的概念便出现了。静态渲染使用的是一种预渲染(pre-render)的方式。也是说在服务器端预先渲染出可以在CDN上缓存的HTML页面,当前端发起请求的时候,直接将渲染好了的文件发送给后端,通过这种方式,就降低了TTFB。相比静态的页面内容而言,JS的文件通常较小,所以在静态渲染的情况下页面的FCP和TTI也不会太高。
|
||||||
|
|
||||||
|
静态渲染一般被称之为静态生成(SSG,static generation),而由此,又引出了静态渐进生成(iSSG,incremental static generation)的概念。我们看到静态生成在处理静态内容,比如“关于我们”的介绍页时是可以的,但是如果内容是动态更新的比如“博客文章”呢?换句话说,就是我们要在面对新页面增加的同时也要支持旧页面的更新,这时渐进静态生成就派上用场了。iSSG可以在SSG的基础上做到对增量页面的生成和存量部分的再生成。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
无论是“关于我们”还是“博客文章”之类的页面,它们虽然从内容的角度看,有静态和动态之分,但总体上都是不需要频繁互动的。但是如果静态渲染的内容需要有相关的行为代码支持互动的情况下,SSG就只能保证FCP,但是很难保证TTI了。这种情况就是我们之前讲到的水合(hydration)可以起到作用的时候了,它可以给静态加载的元素赋予动态的行为。而在用户发起交互的时候,再水合的动作就是渐进式水合(progressive hydration)。
|
||||||
|
|
||||||
|
同时,除了静态渲染和水合可以渐进外,后端渲染也可以通过node中的流(stream)做到后端渐进渲染(progressive SSR)。通过流,页面的内容可以分段传到前端,前端可以先加载先传入的部分。除了渐进式水合外,选择性水合可以利用node stream暂缓部分的组件传输,而将先传输到前端的部分进行水合,这种方式就叫做选择性水合(selective hydration)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
除了上述的这些模式外,还有一种集大成的模式叫做岛屿架构(islands architecture)。就好像我们在地理课学到的,所有的大陆都可以看作是漂流在海洋上的“岛屿”一样,这种模式把页面上所有的组件都看成是“岛屿”。把静态的组件视为静态页面“岛屿”,使用静态渲染;而对于动态的组件则被视为一个个的微件“岛屿”,使用后端加水合的方式渲染。
|
||||||
|
|
||||||
|
加载模式
|
||||||
|
|
||||||
|
为了配合上述的渲染模式,自然需要一系列的加载模式。对于静态内容,就通过静态倒入;动态的内容则通过动态倒入。基于渐进的思想,我们也可以在部分内容活动到特定区域或者交互后,将需要展示的内容渐进地导入。被导入的内容可以通过分割打包(bundle splitting),根据路径(route based splitting)来做相关组件或资源的加载。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
除此之外,另外一个值得了解的模式是PRPL(Push Render, Pre-Cache, Lazy-load)。PRPL模式的核心思想是在初始化的时候,先推送渲染最小的初始化内容。之后在背后通过service worker缓存其它经常访问的路由相关的内容,之后当用户想要访问相关内容时,就不需要再请求,而直接从缓存中懒加载相关内容。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
PRPL的思想是如何实现的呢?这里就要说到HTTP2的特点了。相比HTTP1.1,HTTP2中提供的服务器推送可以一次把初始化所需要的资源以外的额外素材都一并推送给客户端,PRPL就是利用到了HTTP2的这个特点。可是光有这个功能还不够,因为虽然这些素材会保存在浏览器的缓存中,但是不在HTTP缓存中,所以用户下次访问的时候,还是需要再次发起请求。
|
||||||
|
|
||||||
|
为了解决这个问题,PRPL就用到了service worker来做到将服务器推送过来的内容做预缓存。同时它也用到了代码分割(code splitting),根据不同页面的路由需求将不同的组件和资源分割打包,来按需加载不同的内容。
|
||||||
|
|
||||||
|
在说到加载的时候,还有一个我们需要注意的概念就是,pre-fetch不等于pre-load。pre-fetch更多指的是预先从服务器端获取,目的是缓存后,便于之后需要的时候能快速加载。而预加载则相反,是加载特别需要在初始化时使用的素材的一种方式,比如一些特殊字体,我们希望预先加载,等有内容加载时能顺滑地展示正确样式的字体。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
性能优化模式
|
||||||
|
|
||||||
|
前面我们看了JS中特有的一些组件化和加载渲染模式。在加载渲染的模式中,我们已经可以看到提高性能的影子,最后我们再来看一个类别,就是进一步做到前端性能优化的模式,这里值得一提的,包括摇树优化(tree shaking)和虚拟列表优化(list virtualization)。
|
||||||
|
|
||||||
|
摇树优化
|
||||||
|
|
||||||
|
其中,摇树优化的作用是移除 JavaScript上下文中未引用的代码(dead-code)。那为什么我们需要移除这些代码呢?因为这些未被使用的代码如果存在于最后加载的内容中,会占用带宽和内存,而如果它们并不会在程序执行中用到,那就可以被优化掉了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
摇树优化中为什么会有“树”这个字呢?实际上它是一种图结构,但是你可以把它想象成一个类似于AST语法树的结构。摇树算法会遍历整代码中的执行关系,而没有被遍历到的元素则被认为是不需要的,会做相关的“剪枝”。它依赖于ES6中的import和export语句,检测代码模块是否被导出、导入,且被 JavaScript 文件使用。在JavaScript程序中,我们用到的模块打包工具例如webpack或Rollup,准备预备发布代码时,就会用到摇树算法。这些工具在把多个JavaScript文件打包为一个文件时,可以自动删除未引用的代码,使打包后的生成文件更简洁、轻便。
|
||||||
|
|
||||||
|
虚拟列表优化
|
||||||
|
|
||||||
|
说完摇树优化,我们再来看看列表虚拟化,它名字中的“虚拟化”一词从何而来呢?这就有点像我们在量子力学里面提到的“薛定谔的猫”思想,就是我们眼前的事物只有在观测的一瞬间才会被渲染出来,在这里我们的世界就好像是“虚拟”的沙箱。而在虚拟列表中,我们同样也只关注于渲染窗口移动到的位置。这样就可以节省算力和相关的耗时。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在基于React的三方工具中,有支持虚拟列表优化的react-window 和react-virtualized。它们两个的作者是同一个人。相比react-virtualized,react-window结合了上面讲到的摇树优化,同时它也比react-virtualized更加轻量化。感兴趣的同学可以在Github上了解更多。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过今天的学习,我们更系统化地了解了在前端强交互的场景下,响应式编程中的几种设计模式。我们学到了可以通过Hooks来减少组件间嵌套关系,更高效地建立数据、状态和行为上的联系。在加载和渲染的模式我们看到了,为了提高展示和交互的速度,降低资源的消耗,如何渐进式地提供内容和交互。最后在性能优化模式中,我们可以看到更多通过优化资源及节省算力的方式来提高性能的模式。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
我们前面说到了通过Hooks可以减少嵌套,那么你觉得这种方式可以直接取代上下文提供者(context provider)、渲染属性(render props)、高阶组件(HOC)的这些模式吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
162
专栏/JavaScript进阶实战课/27性能:如何理解JavaScript中的并行、并发?(上).md
Normal file
162
专栏/JavaScript进阶实战课/27性能:如何理解JavaScript中的并行、并发?(上).md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
27 性能:如何理解JavaScript中的并行、并发?(上)
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在上一个单元,我们在JS之道的编程模式和JS之法的语法、算法的基础上,又学习了JS之术的设计模式,从今天开始,我们将开启JS之术非功能性优化篇。从前面的学习中,我们可以看到,无论是编程模式、算法还是设计模式都不是独立存在,而是一环扣一环的。那我们今天要讲到的并行、并发,其实也是延续上一讲提到的异步编程模式。
|
||||||
|
|
||||||
|
所以,今天,就让我们了解下并行、并发和异步编程的关系,以及多线程开发在JS中的实现。
|
||||||
|
|
||||||
|
线程vs进程、并行vs并发
|
||||||
|
|
||||||
|
在说到JavaScript中的并发和并行前,我们先来看看程序、线程和进程之间的关系。在一个程序当中,可以有多个进程。在一个进程里,也可以有多个线程。内存在进程间是不共享的,而在线程里,是可以共享的。通过下图,你可以有更直观的理解。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
并发是同一时间段进行的任务,而并行是两个在同一时刻进行的任务。除非是多核CPU,否则如果只有一个CPU的话,多线程本身并不支持并行,而只能通过线程间的切换执行来做到并发。从下图中,你可以对并发和并行有更直观的认识。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
前后端语言如何支持线程并行开发
|
||||||
|
|
||||||
|
如果单从语言的层面来看,JavaScript本身没有线程的规范,也没有内置的对外暴露给开发者用来创建线程的接口。所以JavaScript本身的执行是单线程的,也就是说JavaScript的执行环境中只有一个虚机、指令指针和垃圾回收器。即使是在不同的 realms 中,如浏览器端不同的iframe或服务器端不同的context中,也都只有一条JavaScript指令在同一时间内执行。以此我们可以看出,JavaScript本身并不是多线程友好的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这一点和其它的前端语言很不同,我们知道在iOS、安卓和unity的开发中,开发者分别有GradeCentralDispatch、WorkManager和Job System这些支持并行开发的强大工具包。之所以这些语言不仅支持多线程,还助力并行的开发,是因为这些语言所编写的程序都和前端用户有深度的交互。特别在unity的例子里,需要大量的3D运算,所以必须非常重视性能,让应用快速地做出响应。JavaScript同样作为前端语言,在这方面对比其它的前端语言显得有些不足。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
和前端不同,在很多的后端高级语言,如Ruby或Python中,无论CPU是单核还是多核,线程都是被全局解释器锁(GIL,global interpreter lock)限制并行的,而只支持并发。所以在做多线程开发的时候,一定要注意并行是否受限制,因为如果并行受限,只允许并发的话,那么线程间切换的成本可能高于它带来的好处。在JavaScript的环境中,并没有限制线程的GIL锁,但是JS也有自己的限制,就是它会限制对象在线程间直接的共享,所以这从某个角度来讲也影响了线程在前端的使用。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
JavaScript中的多线程并行开发
|
||||||
|
|
||||||
|
从上面的例子中,我们可以看到,前端语言对线程和并行的支持,总体要优于后端。而JavaScript虽然自带函数式编程的支持,而且在这几年加强了响应式编程的设计思想,但是在多线程和并行开发方面的能力还是有缺失的,但是这并不代表多线程在前端完全不能实现。下面,我们就来看看并行开发在JavaScript中实现的可能性。
|
||||||
|
|
||||||
|
JavaScript中的异步
|
||||||
|
|
||||||
|
先思考一个问题,为什么我们在开篇说并行开发和异步编程相关呢?因为近些年中,JavaScript中大多数的所谓“多任务处理”,都是通过将任务拆分成任务单元后异步执行的。这些不同的任务可以被看做是某种“并发”。
|
||||||
|
|
||||||
|
这里的任务拆分,就是通过我们上节讲过的事件callback回调和promise,将任务分成异步的任务单元。即使是await,底层用的也是callback回调。除了网络,我们上一讲说的用户事件和timeout 等也是同样的任务拆分原理。既然是异步回调,我们知道这种callback带来的并发并不是并行的,也就是说当回调中的代码在执行的时候,是没有其它代码在执行的。换句话说,在异步的场景中,同一时间只有一个调用栈。
|
||||||
|
|
||||||
|
我们说在前端的体验中,最重要的是流畅度(smoothness)和响应度(responsiveness)。那这两个度如何量化呢?
|
||||||
|
|
||||||
|
一般响应度衡量的是我们的程序能不能在100ms之内反应,而流畅度衡量的是每秒60帧(fps)的渲染。所以1000ms除以60帧,也就是说我们有约16.6ms来渲染每一帧,这个16.6ms在前端叫做帧预算。但实际这些工作都是浏览器做的,作为实际的开发者,我们的核心工作是监测用户对页面元素的行为、触发事件、运行相关的事件处理器、对样式进行计算、渲染布局等。
|
||||||
|
|
||||||
|
在之前的响应式编程和设计模式中,我们花了大量的时间学习了如何通过异步的方式和分段渐进的方式,让应用“并发”处理一些任务,从而更快地响应和按需渲染,这些优化也只能从一定程度上解决性能上的问题。因为浏览器的UI主线程是帧同步(lock step)的,就如前面所说,也就是说只有一个JavaScript指令结束后,下一个指令才能执行,所以异步也是有成本的,再加上不同浏览器可能会有不同程度的支持,所以响应和渐进式的设计只能解决一部分性能问题。也就是说响应和渐进式仍然是在串行的基础上操作,而不是在并行的基础上实现的。
|
||||||
|
|
||||||
|
JavaScript中用Web Worker支持并行
|
||||||
|
|
||||||
|
而要真正做到并行开发,实现质的变化,就要用到 WebWorker。它可以打破帧同步,允许我们在和主线程并行的工作线程上执行其它的指令。这里你可能会有疑问,我们前面不是说JavaScript不支持多线程吗?那为什么这里又出现了支持的Web Worker了?这是因为和其它语言不同的是,JavaScript确实没有一个统一的线程实现。
|
||||||
|
|
||||||
|
我们知道的Chrome、Safari和FireFox等浏览器都有自己的虚机来实现JavaScript。这和我们在前端用到的文件系统、网络、setTimeout、设备等功能一样,都是由嵌入环境的Node或浏览器虚机提供,而不是语言本身提供的。所以多线程的接口也是一样,也是由浏览器提供的。而浏览器或虚机提供的支持多线程的API就是Web Worker。它是由万维网联盟(W3C)和网页超文本应用技术工作小组定义(WHATWG)而不是TC39定义的。
|
||||||
|
|
||||||
|
要创建一个Web Worker非常简单,我们只需要类似下面的一个new语句。之后,如果可以通过postMessage在main.js和worker.js之间传递信息,双方也可以通过onMessage来接收来自对方的消息。
|
||||||
|
|
||||||
|
// main.js
|
||||||
|
var worker = new Worker('worker.js');
|
||||||
|
worker.postMessage('Hello world');
|
||||||
|
worker.onmessage = (msg) => {
|
||||||
|
console.log('message from worker', msg.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker.js
|
||||||
|
self.onmessage = (msg) => {
|
||||||
|
postMessage('message sent from worker');
|
||||||
|
console.log('message from main', msg.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
在JavaScript中,有几种不同的工作线程,分别为dedicated worker、shared worker和service worker。我们可以分别来看看它们的作用。这里我们先来看看dedicated worker。
|
||||||
|
|
||||||
|
dedicated worker 只在一个realm中可以使用。它也可以有很多的层级,但是如果层级过多,可能会引起逻辑的混乱,所以在使用上还是要注意。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
和dedicated worker相对的是 shared worker,顾名思义,如果说dedicated是专属的,那么shared则是可共享的,所以shared worker可以被不同的标签页、iframe和worker访问。但shared worker 也不是没有限制,它的限制是只能被在同源上跑的JavaScript访问。在概念上,shared worker是成立的,但是目前支持 shared worker 的虚机或浏览器非常少,并且几乎没法polyfill,所以在这里我们就不做深入的讲解了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
最后,我们再来看看 service worker,它既然叫做服务,那就和后端有关。它的特点就是在前端的页面都关闭的情况下,也可以运行。我们之前一讲提到响应式设计模式的时候,说过的预缓存和服务器推送等概念,离不开service worker的身影。它可以在首次收到浏览器请求时将加载的和可以缓存的内容一同推送给前端做预缓存,当前端再次发起请求时,可以先看是否有缓存,只有在没有的情况下,再从服务端请求。通过这种模式,我们可以大大提高Web应用的性能。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
信息传递模式
|
||||||
|
|
||||||
|
JavaScript要实现多线程的并行开发,最核心的就是要做到信息在主线程和工作线程间的交互,下面我们就来看看如何通过Web Worker定义和JS引擎提供的接口做到这一点。
|
||||||
|
|
||||||
|
结构化拷贝算法
|
||||||
|
|
||||||
|
JavaScript的设计是帧同步(lock step)的,也就是说在main.js所运行的UI主线程上,同时只有一个JavaScript指令可以执行。这样做是为了让前端能把重点放在渲染和用户交互的工作上,而不需要将太多的精力放在和Worker的互动上。但这样的设计也带来一些弊端,比如JavaScript的虚机设计通常不是线程安全(thread safe)的。一个线程安全的数据结构可以在Worker间共享,通过互斥锁(mutex),可以保证不同线程同时访问修改一个数据的时候可以避免竞争条件(race condition)引起的副作用。
|
||||||
|
|
||||||
|
而因为在JavaScript的虚机是非线程安全的,也不存在互斥锁。所以为了数据的安全,对象数据在Worker间是不共享的。如果说数据是不能共享的,那我们之前看到的postMessage中,数据在JavaScript的环境中主线程和工作线程间为什么能传递呢?
|
||||||
|
|
||||||
|
这是因为在前面postMessage例子中的信息传递,不是直接的访问,而是通过先拷贝再传递的方式。这里使用到的一个拷贝算法就是类似我们之前说到的深拷贝,也叫做结构化拷贝(structured clone)。也就是说我们想要通过worker.js来直接访问和控制UI主线程中的main.js中的数据是不可能的。这种通过结构化的拷贝,让main和Worker能在环境之间通信的方法,叫做信息传递(message passing)。
|
||||||
|
|
||||||
|
请求和反馈模式
|
||||||
|
|
||||||
|
在我们前面讲到的hello world的例子中,传递的只是字符串,这么看来可能也没什么问题。但是当我们要传递更复杂的数据结构时,就会出现问题了。
|
||||||
|
|
||||||
|
比如如果我们需要传递的是下面这样一个带有参数的函数调用,在这个时候,我们需要先将函数调用转化为一个序列,也就是一个对应的是我们的本地调用远程过程调用,叫做PRC(Remote Procedure Call)。基于postMessage异步的特性,它返回的不是一个结果,而是一个await 的 promise。
|
||||||
|
|
||||||
|
isOdd(3);
|
||||||
|
is_odd|num:3
|
||||||
|
|
||||||
|
worker.postMessage('is_odd|num:3');
|
||||||
|
worker.postMessage('is_odd|num:5');
|
||||||
|
|
||||||
|
worker.onmessage = (result) => {
|
||||||
|
// 'true'
|
||||||
|
// 'false'
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
也就是说,在worker.js做完计算之后,会将结果返回。这时,会有另外一个问题,就是如果我们有很多个不同的请求,怎么能知道返回的结果和请求的对应关系呢?
|
||||||
|
|
||||||
|
为了解决这个问题,我们可以用JSON格式的JSON-PRC。结构化拷贝算法支持除了Symbol以外的其它类型的原始数据,这里包含了布尔、null、undefined、数字、BigInt和我们用到的字符串。结构化拷贝算法也可以支持多种的数据结构,包括数组、字典和集合等。还有就是用来存储二进制数据的ArrayBuffer、ArrayBufferView和Blob也可以通过postMessage传递。ArrayBuffer也可以解决数据传递中性能的问题,但是函数(function)和(class)类是不能通过postMessage来传递的。
|
||||||
|
|
||||||
|
// worker.postMessage
|
||||||
|
{"jsonrpc": "2.0", "method": "isOdd", "params": [3], "id": 1}
|
||||||
|
{"jsonrpc": "2.0", "method": "isEven", "params": [5], "id": 2}
|
||||||
|
// worker.onmessage
|
||||||
|
{"jsonrpc": "2.0", "result": "false", "id": 2}
|
||||||
|
{"jsonrpc": "2.0", "result": "true", "id": 1}
|
||||||
|
|
||||||
|
|
||||||
|
命令和派发模式
|
||||||
|
|
||||||
|
上面我们看到了请求和反馈间的映射,下面,我们再来看看命令和派发的映射。比如我们有两个功能,一个是判断奇数,另外一个是判断偶数,但是这两个数据是在两个不同代码路径上的,这个时候,我们也需要某种方式的映射。这里可以使用字典,或者更简单的对象结构也能够支持指令中这种映射关系。这种通过开发者自己实现的一个来保证指令派发的逻辑就叫做 command dispatcher 模式。
|
||||||
|
|
||||||
|
var commands = {
|
||||||
|
isOdd(num) { /*...*/ },
|
||||||
|
isEven(num) { /*...*/ }
|
||||||
|
};
|
||||||
|
|
||||||
|
function dispatch(method, args) {
|
||||||
|
if (commands.hasOwnProperty(method)) {
|
||||||
|
return commands[method](...args);
|
||||||
|
}
|
||||||
|
//...
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
通过上述结构化拷贝算法,再加上请求和反馈,以及命令和派发模式,就可以通过 JS 引擎提供的 Web Worker 接口,在主线程和工作线程间实现信息的传递了。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
今天,我们了解了线程和进程、并行和并发的区别,也对比了前后端的不同语言对多线程并行开发的支持。针对JavaScript,我们也看到了多线程开发中核心的信息传递机制,包括结构化拷贝算法,以及基于postMessage自身的不足,如何通过JSON-RPC和command dispatch的方式来解决相关的问题。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
这道思考题也是下一讲的铺垫。我们课程中说,在信息传递层面,我们可以使用ArrayBuffer来提高性能,你知道它是如何做到的以及背后的原理吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
168
专栏/JavaScript进阶实战课/28性能:如何理解JavaScript中的并行、并发?(下).md
Normal file
168
专栏/JavaScript进阶实战课/28性能:如何理解JavaScript中的并行、并发?(下).md
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
28 性能:如何理解JavaScript中的并行、并发?(下)
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在上一讲中,我们初步介绍了并发和并行的概念,对比了不同语言对多线程开发的支持。我们也通过postMessage,学习了用信息传递的方式在主线程和Worker线程间实现交互。但是,我们也发现了JavaScript对比其它语言,在多线程方面还有不足,似乎信息传递本身不能让数据在不同的线程中真正做到共享,而只是互传拷贝的信息。
|
||||||
|
|
||||||
|
所以今天,我们再来看看如何能在信息互传的基础上,让数据真正地在多线程间共享和修改。不过更重要的是,这种修改是不是真的有必要呢。
|
||||||
|
|
||||||
|
SAB+Atomics模式
|
||||||
|
|
||||||
|
前面,我们说过对象的数据结构在线程间是不能共享的。如果通过postMessage来做信息传递的话,需要数据先被深拷贝。那有没有什么办法能让不同的线程同时访问一个数据源呢?答案是有,要做到数据的共享,也就是内存共享,我们就需要用到 SAB(SharedArrayBuffer)和 Atomics。下面,我们先从SAB开始了解。
|
||||||
|
|
||||||
|
共享的ArrayBuffer
|
||||||
|
|
||||||
|
SAB是一个共享的ArrayBuffer内存块。在说到SAB前,我们先看看ArrayBuffer是什么,这还要从内存说起。我们可以把内存想象成一个储藏室中的货架,为了找到存放的物品,有从1-9这样的地址。而里面存储的物品是用字节表示的,字节通常是内存中最小的值单元,里面可以有不同数量的比特(bit),比如一个字节(byte)里可以有8、32或64比特。我们可以看到 bit 和 byte 它俩的英文写法和读音有些相似,所以这里要注意不要把字节和比特混淆。
|
||||||
|
|
||||||
|
还有一点需要注意的是,计算机在内存中的数据存储是二进制的,比如数字2的二进制写法就是00000010,用8个比特来表示,就如下图所示。如果是字母的话,则可以先通过UTF-8这样的方式,先转换成数字,再转换为二进制。比如字母H,转换成数字就是72,再转换为二进制就是01001000。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在JavaScript语言当中,内存管理是自动的,也就是说当我们敲一行代码后,我们的虚机会自动地帮助我们在内存中找到剩余的空间,把数据放进去存储。并且会追踪这段代码在我们的程序中是否还可以被访问到,如果发现已经无法访问了,就会做相关的清除处理。这个过程也被称之为垃圾回收。
|
||||||
|
|
||||||
|
如果你使用C语言编写再编译成WebAssembly的话,那么基于C语言的手动内存管理和垃圾回收的机制,你就需要通过内存分配(malloc)的功能从一个空闲列表(free list)中找到存放位置来存放,使用后,再通过释放(free)的功能将内存释放。
|
||||||
|
|
||||||
|
再回到JavaScript的场景中,为什么我们前面要介绍自动和手工的内存管理呢?
|
||||||
|
|
||||||
|
这也就回到了我们上一讲最后留的问题了,就是为什么说使用ArrayBuffer的性能更高。这里我们顺便解决下上节课的思考题,如果在开发中使用更高级的数据类型,并且把数据处理的工作完全交给JavaScript的虚机如V8来处理,这样确实能给我们带来方便,但同时,副作用就是降低了性能极度调优的灵活性。比如当我们创建一个变量的时候,虚机为了猜测它的类型和在内存中的表现形式,可能要花费2-8倍的内存。而且有些对象创建和使用的方式,可能会增加垃圾回收的难度。
|
||||||
|
|
||||||
|
但如果我们使用ArrayBuffer这样更原始的数据类型,并通过C语言来编写程序并编译成WebAssembly的话,可以给开发者更多的控制,来根据使用场景更细粒度地管理内存的分配,提高程序的性能。
|
||||||
|
|
||||||
|
那一个ArrayBuffer和我们经常用的数组有什么区别呢?一起来看下面的代码,一个普通的数组中可以有数字、字符串、对象等不同类型的数据,但是在ArrayBuffer当中,我们唯一可用的就是字节。
|
||||||
|
|
||||||
|
// 数组
|
||||||
|
[5, {prop: "value"}, "一个字符串"];
|
||||||
|
|
||||||
|
[0] = 5;
|
||||||
|
[1] = {prop: "value"};
|
||||||
|
[2] = "一个字符串";
|
||||||
|
|
||||||
|
// ArrayBuffer
|
||||||
|
[01001011101000000111]
|
||||||
|
|
||||||
|
|
||||||
|
这里的字节虽然可以用一串数字表示,但是有个问题是,机器怎么能知道它的单位呢?比如我前面介绍的,这串数字本身是没意义的,只有根据不同的8、32或者64比特单位,它才能具有意义。这时,我们就需要一个view来给它分段。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
所以一个ArrayBuffer中的数据是不能直接被操作,而要通过 TypedArray 或 DataView 来操作。
|
||||||
|
|
||||||
|
// main.js
|
||||||
|
var worker = new Worker('worker.js');
|
||||||
|
|
||||||
|
// 创建一个1KB大小的ArrayBuffer
|
||||||
|
var buffer = new SharedArrayBuffer(1024);
|
||||||
|
|
||||||
|
// 创建一个TypedArray的DataView
|
||||||
|
var view = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
// 传递信息
|
||||||
|
worker.postMessage(buffer);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
// buffer中的第1个字节
|
||||||
|
console.log('later', view[0]); // later 5
|
||||||
|
// buffer中foo的属性值
|
||||||
|
console.log('prop', buffer.foo); // prop 32
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// worker.js
|
||||||
|
self.onmessage = ({data: buffer}) => {
|
||||||
|
buffer.foo = 32;
|
||||||
|
var view = new Uint8Array(buffer);
|
||||||
|
view[0] = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
其实一个ArrayBuffer或SAB在初始化的时候,也是要用到postMessage和结构化拷贝算法的。但是和信息传递不同的是,这里在请求端发起请求时传入的数据,被拷贝后,如果在接收端做了修改,这个修改后的数据的指向和之前的数据是一致的。我们可以对比下普通的postMessage和ArrayBuffer以及SAB的区别。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
所以,我们在上面SAB的例子中可以发现,通过setTimeout而不是onmessage,就可以获取在worker.js修改后的buffer的字节和属性了。但这里需要注意的是,字节的数量在SAB中是一开始就定好,且不可修改的。
|
||||||
|
|
||||||
|
Atomics和原子性
|
||||||
|
|
||||||
|
说完了SharedArrayBuffer,我们再来看看原子性。既然数据要共享,就要考虑原子性的问题。
|
||||||
|
|
||||||
|
如果你有做过数据库开发的话,应该听过 ACID 的原则,它是原子性、一致性、隔离性、持久性的缩写。原子性指的是一个事务中的所有操作,或者全部完成,或者全部不完成,不会结束在中间环节。任务在执行过程中发生的错误,都会被回滚到初始状态。这样做的结果是,事务不可分割、不可约简。
|
||||||
|
|
||||||
|
那为什么在数据库的开发中,会如此重视原子性呢?
|
||||||
|
|
||||||
|
你可以想想,如果我们单独看一个客户端的请求,它可能是原子性的,可如果是几个请求,可能就不是原子性的了。但是如果这些请求都属于同一个交易,那么当用户成功付款后,付款结果没能抵达电商接口,这个交易是不完整的,不仅在经济上可能造成损失,并且会给客户带来很不好的体验。所以从这个角度来看,包含这三条请求的整个交易就是一个原子性事务。
|
||||||
|
|
||||||
|
同样的,在分布式的设计中,一个网络中的不同节点间的互动也应该保证原子性的原则。那么再回到线程,我们说一个计算机中不同的线程对一个共享的数据也应该保持原子性的操作。
|
||||||
|
|
||||||
|
那这时你可能会问,如我们之前所说,并发中我们的程序就很容易进入一个竞争条件(race condition),那既然在并发设计中需要让事务保持原子性,那在JavaScript中面对并发怎么处理?
|
||||||
|
|
||||||
|
别担心,这个问题可以通过JavaScript提供的原子(Atomics)来解决。Atomics提供了所需的工具来进行原子性的操作,并且提供了线程安全的等待机制。在JavaScript中,有一个全局的Atomics对象,它自带一些内置的方法。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在SAB的内存管理中,上述这些方法可以解决3大类的问题。第一个问题是在单个操作中的竞争条件,第二个问题是在多个操作中的竞争条件,第三个问题是在指令顺序造成的问题。下面,我们依次来看一下。
|
||||||
|
|
||||||
|
单个操作中的竞争条件
|
||||||
|
|
||||||
|
这里你可能会好奇,一个单个操作为什么还会有竞争?举个例子,如果我们用2个工作线程,都对一个数字做+1的增量运算,你可能觉得无论谁操作都一样,结果都是+1,但是问题并没有这么简单。因为在实际计算的时候,我们的数据是会从内存中取出,放到寄存器里,然后通过运算器来运算的,这个时候,如果有一个数字6,同时被工作线程1和2取出,然后计算+1,那么结果可能就是7,而不是8。因为这两个线程在访问内存中的数据计算前收到的都是6,所以+1的结果被覆盖计算了2次。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那为了解决这个问题,上面提到的 Atomics.add()、Atomics.sub()、Atomics.and()、Atomics.or()、Atomics.xor()、Atomics.exchange() 等运算就是很好地避免这一类问题的方法。如果要做乘法除法,则可以通过Atomics.compareExchange()来创建相关的功能。
|
||||||
|
|
||||||
|
多个操作中的竞争条件
|
||||||
|
|
||||||
|
说完了单个操作中的竞争条件,我们再来看看多个操作中的竞争条件。在JavaScript中,我们可以通过 futex 来起到互斥锁的作用。它来源于Linux内核中,有一种互斥锁(mutex)叫做快速用户空间互斥体(futex,fast userspace mutex)的锁。futex中有两个方法,一个是Atomics.wait(),另外一个是Atomics.wake()。这里也很好基于字面意思来理解,一个代表等待,一个代表唤醒。
|
||||||
|
|
||||||
|
在用锁的时候,我们要注意,前端浏览器中主线程是不允许加锁的,在后端的Node中,主线程是可以加锁的。之所以在前端浏览器中不能加锁,是因为阻碍JavaScript的主线程执行,对用户体验的影响实在太大了,而对于后端来讲,则没有这么直接的影响。
|
||||||
|
|
||||||
|
如果在前端主线程想使用wait(),也不是完全没办法,这里可以使用waitAsync()。相比wait()可以暂停主线程再传递字符串,waitAsync要另起线程,所以从性能上来说,它比wait()会差一些。所以对于热路径(hotpath),也就是程序中那些会被频繁执行到的代码来说,可能不是那么有用,但是对于非信息传递类的工作来说,比如通知另外的线程,它还是有用的。
|
||||||
|
|
||||||
|
指令顺序造成的竞争条件
|
||||||
|
|
||||||
|
最后,我们再来看看指令顺序造成的竞争条件。如果你对计算机有芯片层面的理解的话,就会知道我们的代码在指令执行的流水线层面会被重新排序。如果在单线程的情况下,这可能不会造成什么问题,因为其它的代码需要在当前的函数在调用栈中完成执行才看到结果。但是如果在多线程下,其它的线程在结果出现前,就可能看到变化而没有考虑后序运行的代码指令结果。那这个问题要怎么解决呢?
|
||||||
|
|
||||||
|
这个时候,就要用到 Atomics.store() 和 Atomics.load()。函数中 Atomics.store() 以前的所有变量更新都保证在将变量值写回内存之前完成,函数中 Atomics.load() 之后的所有变量加载都保证在获取变量值之后完成。这样就避免了指令顺序造成的竞争条件。
|
||||||
|
|
||||||
|
数据传输的序列化
|
||||||
|
|
||||||
|
在使用SAB的时候,还有一点需要注意的是数据的序列化,也就是说我们在使用它来传递字符串、布尔或对象的时候,都需要一个编码和解码的过程。特别是对象,因为我们知道对象类型是没法直接传递的,所以这个时候,我们就需要用到“通过JSON将对象序列化成字符串”的方法,所以它更适合用postMessage和onmessage来做传递,而不是通过SAB。
|
||||||
|
|
||||||
|
Actor Model模式
|
||||||
|
|
||||||
|
通过上面的例子,我们可以看出,直接使用SAB+Atomics的方式还是蛮复杂的,稍有不慎,可能引起的性能问题远远大于优化的效果。所以除非真的是研发型的项目,否则只是纯应用类的项目,最好是通过一些成熟的库或者WebAssembly将复杂的内存管理抽象成简单的接口,这样的方式会更加适合。另外,我们也可以看看SAB+Atomics的一个替代方案,Actor Model 模式。
|
||||||
|
|
||||||
|
在Actor Model模式中,因为Actor是分布在不同的进程里的,如我们之前所说,进程间的内存是不共享的,每个Actor不一定在同一个线程上,而且各自管理自己的数据,不会被其它线程访问到,所以就没有互斥锁和线程安全的问题。Actor之间只能互相传递和接收信息。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这种模式更符合JavaScript的设计,因为它本身就对手动内存管理的支持度不高,所以在 Actor Model 这种模式下,我们只是通过线程做信息传递,而不做共享。但是,这并不代表主线程和Worker间的影响就仅限于信息传递,比如通过Worker传给主线程的数据,主线程完全可以自行基于接收到的数据来改变DOM,只是在这个过程中,需要自己做一些转换的工作。
|
||||||
|
|
||||||
|
这里,我们针对数据量较大的信息传递时,应该注意一些优化策略:
|
||||||
|
|
||||||
|
|
||||||
|
我们可以将任务切分成小块儿依次传递;
|
||||||
|
每次我们可以选择传递delta,也就是有变化的部分,而不是将全量数据进行传递;
|
||||||
|
如果我们传递的频率过高,也可以将消息捆绑来传递;
|
||||||
|
最后一点就是通过ArrayBuffer提高性能。
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过这两节课的学习,我们可以看到多线程的开发在前端还有很长的路要走。
|
||||||
|
|
||||||
|
我们也看到了SAB+Atomics的模式,虽然从某种程度上看,在JavaScript中可以实现,但实际上,Actor Model更易于在JavaScript中的使用,特别是在前端场景中。很明显,我们不想因为多线程对同一组对象的并行修改,而引起竞争条件;或者为了数据在内存中的共享,增加过于复杂的逻辑来支持数据的编码、解码和转换;亦或为了手工的内存管理,增加额外的逻辑。
|
||||||
|
|
||||||
|
虽然多线程的开发,在前端更多处于实验性阶段,但是我认为它还是有着很大的想象空间的。因为前端如果有着比较耗内存的需要大量运算的任务,可以交给Worker Thread来处理,这样JavaScript的主线程就可以把精力放在UI渲染上。特别是通过Actor模式,可以大大提高主程序的性能,同时又避免副作用。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
我们说过对象在线程间是不能共享的,那通过SharedArrayBuffer你觉得可以实现对象的共享吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
117
专栏/JavaScript进阶实战课/29性能:通过Orinoco、JankBusters看垃圾回收.md
Normal file
117
专栏/JavaScript进阶实战课/29性能:通过Orinoco、JankBusters看垃圾回收.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
29 性能:通过Orinoco、Jank Busters看垃圾回收
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在前两讲中,我们从多线程开发的角度了解了JavaScript中的性能优化。
|
||||||
|
|
||||||
|
今天,我们再来看一下JavaScript中内存管理相关的垃圾回收(garbage collection)机制,以及用到的性能优化的相关算法。
|
||||||
|
|
||||||
|
实际上,在JS语言中,垃圾回收是自动的,也就是说并不是我们在程序开发中手工处理的,但是,了解它对理解内存管理的底层逻辑还是很有帮助的。特别是结合我们前面两节课讲到的,在前端场景中,当我们的程序使用的是图形化的WebGL+Web Worker的多线程来处理大量的计算或渲染工作时,了解内存管理机制则是非常必要的。特别提醒一下,这节课会涉及到比较多的理论和底层知识,一定要跟紧我们的课程节奏啊。
|
||||||
|
|
||||||
|
闲置状态和分代回收
|
||||||
|
|
||||||
|
我们在上一讲说到并行和并发的时候,有讲到过,前端的性能指标中,我们通常关注的是流畅度和反应度。在理想的状态下,为了获得丝滑流畅的体验,我们需要达到60fps,也就是每帧在16.6ms内渲染。在很多的情况下,浏览器都可以在16.6ms内完成渲染,这个时候,如果提前渲染完了,剩下的时间我们的主线程通常是闲置(idle)的状态。而Chrome通常会利用这个闲置的时间来做垃圾回收。通过下面的图示,我们可以更加直观地看到主线程上这些任务的执行顺序。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在内存管理中,我们有必要先了解几个概念。在垃圾回收中,有个概念是分代回收(generational garbage collector),它所做的是将内存堆(memory heap)分代,不同类型的对象被分到半空间(semi space),里面包括了年轻代(young generation)和老年代(old generation)。
|
||||||
|
|
||||||
|
这样的分代专区,是基于垃圾回收界的一个著名的代际假说(generational hypothesis)来设置的。在这个假说当中,会认为年轻代中是较新的数据,这些数据中大多对象的生命周期都比较短;而那些在老年代中存活下来的数据,它们的生命周期又会特别长。
|
||||||
|
|
||||||
|
所以在V8中有一副一主两个垃圾回收器,分别负责年轻代和老年代的垃圾回收。
|
||||||
|
|
||||||
|
副垃圾回收器(minor GC,scavenger)的作用就是回收新生代中生命周期较短的对象,并且将生命周期较长的对象移动到老年代的半空间。年轻代空间里又包含对象区域(from-space)和空闲区域(to-space)。
|
||||||
|
|
||||||
|
这里你可能会想,为啥年轻代里还要再分两个区呢?因为这样方便对数据进行处理。在对象区域,数据会被标记成存活和垃圾数据。之后垃圾数据会被清除,存活数据会被晋升整理到空闲区域。这时,空闲区域就变成了对象区域,对象区域就变成了空闲区域。也就是说在不创建新的区域的情况下,可以沿用这两个区域交换执行标记和清除的工作。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
而主垃圾回收器(major GC)会在老年代半空间中的对象增加到一定限度的时候,对可以清除的对象做渐进的标记。通常当页面在很长一段时间空闲时,会进行全量清理,清除的动作是由专属的清除线程来完成的,最后对于碎片化的内存还要进行整理的动作。所以整体下来,主回收器的操作流程是标记-清除-整理(mark-sweep-compact)。
|
||||||
|
|
||||||
|
在内存管理中,特别是垃圾回收中,它的底层逻辑其实很简单,总结起来其实就3点:
|
||||||
|
|
||||||
|
|
||||||
|
如何标记存活的对象;
|
||||||
|
回收扫清非存活的对象;
|
||||||
|
回收后对碎片进行整理。主回收器也不例外。
|
||||||
|
|
||||||
|
|
||||||
|
首先,我们先来看一下标记(mark)。标记是找到可触达对象的过程。垃圾回收器通过可触达度来判断对象的“活跃度”。这也就代表着要保留运行时(runtime)内部当前可访问的任何对象,同时回收任何无法访问的对象。标记从一组已知的对象指针开始,如全局对象和执行堆栈中当前活动的函数,称为根集。
|
||||||
|
|
||||||
|
GC将根(root)标记为存活的,并根据指针递归发现更多存活对象,标记为可访问。之后,堆上所有未标记的对象都被视为无法从应用程序访问的可回收对象。从数据结构和算法的角度,我们可以把标记看作是图的遍历,堆上的对象是图的节点(node)。从一个对象到另一个对象的指针是图的边(edge)。基于图中的一个节点,我们可以使用对象的隐藏类找到该节点的所有向外边缘。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
标记完成后,在清除(sweep)的过程中,GC会发现无法访问的对象留下的连续间隙,并将它们添加到一个被称为空闲列表(free list)的数据结构中。空闲列表由内存块的大小分隔,以便快速查找。将来,当我们想分配内存时,我们只需查看空闲列表并找到适当大小的内存块即可。
|
||||||
|
|
||||||
|
清除后的下一步就是整理(compact),你可以把它想象成我们平时电脑上的硬盘碎片整理,将存活的对象复制到当前未被压缩的其它内存页中(使用内存页的空闲列表)。通过这种方式,可以利用非存活的对象留下的内存中的小而分散的间隙,这样可以优化内存中的可用空间。
|
||||||
|
|
||||||
|
V8的 Orinoco 项目是为了能不断提高垃圾回收器的性能而成立的,它的目的是通过减少卡顿(jank buster),提高浏览器的流畅度和响应度。在这个优化的过程中,V8 在副回收器中用到了并发和并行。下面,我们就分别来看看它们的原理及实现。
|
||||||
|
|
||||||
|
副回收器中使用的并行
|
||||||
|
|
||||||
|
首先,我们先来看看副回收器(minor GC,Scavenger)用到的并行回收(Scavenger Parallel)。平行回收,顾名思义,就是垃圾回收的工作是在多线程间平行完成的。相比较并发,它更容易处理,因为在回收的时候,主线程上的工作是全停顿的(stop the world)。
|
||||||
|
|
||||||
|
V8用并行回收在工作线程间分配工作。每个线程会被分到一定数量的指针,线程会根据指针把存活的对象疏散到对象空间。因为不同的任务都有可能通过不同的路径找到同一个对象并且做疏散和移动的处理,所以这些任务是通过原子性的读写、对比和交换操作来避免竞争条件的。成功移动了对象的线程会再更新指针供其它线程参考更新。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
早期,V8所用到的是单线程的切尼半空间复制算法(Cheney’s semispace copying algorithm)。后来把它改成多线程。与单线程一样,这里的收集工作主要分3步:扫描根、在年轻代中复制、向老年代晋升以及更新指针。
|
||||||
|
|
||||||
|
这三步是交织进行的。这是一个类似于霍尔斯特德半空间复制回收器(Halstead’s semispace copying collector)的GC,不同之处在于,V8使用动态工作窃取(work stealing)和相对较为简单的负载均衡机制来扫描根。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在此期间,V8也曾尝试过一种叫做标记转移(Mark Evacuate algorithm)的算法。这种算法的主要优点是,可以利用V8中已经存在的较为完整的Mark Sweep Compact收集器作为基础,进行并行处理。
|
||||||
|
|
||||||
|
这里的并行处理分为三步:首先是将年轻代做标记;标记后,将存活的对象复制到对象空间;最后更新对象间的指针。
|
||||||
|
|
||||||
|
这里虽然是多线程,但它们是锁步(lock step)完成的,也就是说虽然这三步本身可以在不同的线程上平行执行,但线程之间必须在同步后再到下一阶段。所以,它的性能要低于前面说的交织完成的Scavenger并行算法。
|
||||||
|
|
||||||
|
主回收器中使用的并发
|
||||||
|
|
||||||
|
并发标记
|
||||||
|
|
||||||
|
说完了副回收器中的并行GC,我们再来看看主回收器中用到的并发标记(concurrent marking)。在主线程特别繁忙的情况下,标记的工作可以独立在多个工作线程上完成标记操作。但由于在此期间,主线程还在执行着程序,所以它的处理相比并行会复杂一些。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在说到并发标记前,我们先来看看标记工作怎么能在不同的线程间同时执行。在这里,对象对于不同的主线程和工作线程是只读的,而对象的标记位和标记工作列表是既支持读,也支持写访问的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
标记工作列表的实现对性能至关重要,因为它可以平衡“完全使用线程的局部变量”或“完全使用并发”的两种极端情况。
|
||||||
|
|
||||||
|
下图显示了V8使用基于分段标记的工作列表的方法,来支持线程局部变量的插入和删除,从而起到平衡这两种极端场景的作用。一旦一个分段已满,就会被发布到一个共享的全局池中,在那里它可以被线程窃取。通过这种方式,V8允许标记工作线程尽可能长时间地在本地运行而不进行任何同步。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
增量标记
|
||||||
|
|
||||||
|
除了并发标记法之外,主回收器还用到了增量标记法,也就是利用主线程空闲时间处理增量标记的任务。为了实现增量标记,要保证之前进行一半的工作有“记忆”;同时要处理期间JavaScript对原有对象可能造成的变化。在这里,V8运用了三色标记法和写屏障。
|
||||||
|
|
||||||
|
三色标记法的原理是将从根部开始引用的节点标记成黑、灰和白色。黑色是引用到也标记处理的,灰色是引用到但未标记处理的,白色是未被引用到的。所以当没有灰色节点的时候,便可以清理,如果有灰色的,就要恢复标记,之后再清理。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
因为增量标记是断断续续进行的,所以被标记好的数据存在可能被JavaScript修改的情况,比如一个被引用的数据被重新指向了新的对象,它和之前的对象就断开了,但因为垃圾回收器已经访问过旧的节点,而不会访问新的,新的节点就会因此而被记录成未被引用的白色节点。所以在这里必须做一个限制,就是不能让黑色节点指向白色的节点。而这里的限制就是通过一个写屏障来实现的。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这节课我们通过V8,了解了JS引擎是如何利用闲置状态来做垃圾回收的,以及考虑到程序性能时,这种回收机制可能带来的卡顿和性能瓶颈。我们看到Chrome和V8为了解决性能问题,通过分代和主副两个回收器做了很多的优化。这里面也用到了很多并发和并行的工作线程来提高应用执行的流畅度。
|
||||||
|
|
||||||
|
虽然对于一般的Web应用,这些问题并不明显。但是随着Web3.0、元宇宙的概念的兴起,以及WebGL+Web Worker的并行、并发实现越来越普及,由此使得前端对象创建和渲染工作的复杂度在不断提高,所以内存管理和垃圾回收将是一个持续值得关注的话题。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
虽然我们说JavaScript的垃圾回收是自动的,但是我们在写代码的时候,有没有什么“手工”优化内存的方法呢?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
190
专栏/JavaScript进阶实战课/30网络:从HTTP_1到HTTP_3,你都需要了解什么?.md
Normal file
190
专栏/JavaScript进阶实战课/30网络:从HTTP_1到HTTP_3,你都需要了解什么?.md
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
30 网络:从HTTP_1到HTTP_3,你都需要了解什么?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
说到HTTP你可能并不陌生,但是你真的确定你对它有所了解吗?或者你可能会觉得了解它对前端开发没什么用。但实际上,对HTTP的理解可以帮助我们更好地制定前端应用层性能优化的策略。
|
||||||
|
|
||||||
|
所以今天,我们就来看看不同的HTTP版本。对于HTTP/1的不足之处,我们能做哪些优化;对于HTTP/2的优点,我们如何加以利用;对于HTTP/3,我们又可以有哪些期望。下面,我们就先从HTTP的前世今生开始说起吧。
|
||||||
|
|
||||||
|
HTTP/1.0
|
||||||
|
|
||||||
|
HTTP最早的版本是1991年由万维网之父蒂姆·伯纳斯-李(Tim Berners-Lee)定义的,这个HTTP/0.9的版本起初只有一页纸一行字的描述。HTTP/0.9仅支持GET方法,仅允许客户端从服务器检索HTML文档,甚至都不支持任何其他文件格式或信息的上传。
|
||||||
|
|
||||||
|
之后的HTTP/1是从1992年开始草拟到1996年定版的,这个版本的协议就包含了更多的我们如今耳熟能详的元素和功能,比如我们常用的headers、errors、redirect都是在这个版本中出现了。但是它仍然有几个核心问题:一是没有办法让连接在不同请求间保持打开;二是没有对虚拟服务器的支持,也就是说在同一个IP上没有办法搭建多个网站;三是缺乏缓存的选项。
|
||||||
|
|
||||||
|
时隔半年,紧跟其后出现的就是HTTP/1.1,它的出现解决了上述问题。但是相比互联网的使用者日益增加和Web应用的不断兴起,HTTP的标准在之后的近二十年里却几乎止步不前。在这期间,一些瓶颈没有得到进一步的解决,造成的问题日益凸显。
|
||||||
|
|
||||||
|
这些性能上的瓶颈可以归为几个方面:
|
||||||
|
|
||||||
|
|
||||||
|
延时,也就是一个IP包从一点到另外一点间的时长。而且当我们考虑往返时间(RTT)的时候,要计算两倍的延时;一个HTTP请求又可能都包含多个往返时间,这时延时会进一步增加。
|
||||||
|
带宽,这就好比我们每天开车的路一样,车道越窄,车流越大,越有可能造成阻塞,而多车道的道路,通常会减少通勤时间。
|
||||||
|
连接时间,一次网络连接要经过三次握手,第1次是客户端对服务器端发起的同步请求,第2次是服务器发给客户端的获知,第3次是客户端发给服务器端的获知。
|
||||||
|
TLS协商时间,这个是为了建立HTTPS的安全连接所需要的在三次握手基础上的额外处理时间。
|
||||||
|
|
||||||
|
|
||||||
|
与此同时,开发者就想出了一系列的“曲线救国”的方案。这里包含了利用HTTP的流水线,使用域名分片,捆绑资源以减少HTTP请求,内联较小的资源等等。下面,我们可以看看在这一时期,人们所使用的一些性能优化方案。
|
||||||
|
|
||||||
|
持久HTTP加流水线
|
||||||
|
|
||||||
|
首先我们来看看持久HTTP加流水线的方案。在下面的例子里我们看到,如果前端想要请求HTML和CSS这两个资源,在先后请求的情况下,我们要建立两次连接。每次3次握手的话,两次请求就是一共6次握手。显然这么操作是很费时的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
持久HTTP(Keep Alive)允许我们在多个应用程序请求之间重用现有连接。通过对持久HTTP的使用,我们可以减少握手的时间。但它仍然不是最优解,因为使用持久HTTP,也就意味着客户端上有严格的先入先出(FIFO)排队顺序,第一个请求先发送,等待完整响应,然后再发起下一个请求。从下图(左),我们可以看到它的流程。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
为了能更进一步优化,HTTP流水线则可以在持久HTTP的工作流上做一个优化,也就是将FIFO队列从客户端(请求队列)改到服务器(响应队列)。从上图(右)中,我们可以看出它的流程。对比在左图中持久HTTP的例子,CSS的请求要等到HTML的请求返回后再进行;而在右图HTTP的工作流的例子中,我们可以看到两个请求可以同时进行。这就进一步大大减少了总时长。
|
||||||
|
|
||||||
|
但是在使用持久HTTP加流水线的时候,因为连接是一直开放的,要特别注意安全性,使用HTTPS的连接。在一些实际的案例中,比如苹果的iTunes就通过这种方式提高了应用的性能。
|
||||||
|
|
||||||
|
域名分片
|
||||||
|
|
||||||
|
另外一个非常常见的在HTTP/1.1时代的性能优化方案是域名分片,它的核心是通过建立更多的子域名来对资源进行加载。为什么要这么做呢?
|
||||||
|
|
||||||
|
我们可以以高速公路为例,在HTTP/1.1中,一个主机只能有6个TCP连接,也就相当于6车道。当我们想在同一时间让更多的资源加载,就可以创建多个主机,这样就可以有更多的TCP连接。比如我们如果有3个子域名,就是3乘以6,等于18条车道,这样在同一时间就有更多的资源可以加载。但是这个方案也不是一劳永逸的,每个新的主机名都需要额外的DNS查找,为每个额外的socket消耗额外资源,而且需要开发手动管理资源的分片位置和方式。所以在使用的时候还是要综合考虑。
|
||||||
|
|
||||||
|
捆绑资源以减少HTTP请求
|
||||||
|
|
||||||
|
另外一个HTTP/1.1时代的科技与狠活儿就是资源捆绑,在图片层面使用的是CSS精灵(CSS sprite),JS或CSS类的文件则用的是打包的技术。CSS精灵的实现是将不同的图片元素整合在一张图加载,通过CSS控制显示的位置。JS或CSS类的文件打包就是将不同的CSS和JS文件打包成一个,来减少请求。
|
||||||
|
|
||||||
|
同样的,这些方法有利有弊,这样的优化虽然减少了请求和返回的数量,但是同时也造成了一些问题。比如捆绑后的资源可能并不是在当前页面都受用的,这样就可能造成资源的浪费。所以,在使用这种优化方案的时候,也要结合实际场景考虑。
|
||||||
|
|
||||||
|
内联较小的资源
|
||||||
|
|
||||||
|
最后,我们再来看看内联。使用数据URI,我们可以在页面对较小的资源做内联。这样,可以减少请求的数量。同样,这种方式是一把双刃剑,如果什么资源都加载到页面里,也会增加一次性加载的负担。所以通常的最佳实践是1~2KB大小的数据,可以用这种方式来内联,但是如果是过于大的资源,则不适合用这种方式了。
|
||||||
|
|
||||||
|
HTTP/2.0
|
||||||
|
|
||||||
|
说完了HTTP/1.1时代的历史和性能优化,下面,我们再来看看HTTP/2.0时代。你可能会问,为什么HTTP在1.1版之后,这么长时间都没有什么变化呢?这是因为它的升级成本太高了,要考虑到浏览器、服务器、代理,还有其它的中间件都需要协调才能完成切换,这就代表着各种兼容性问题可能会导致应用的服务中断。所以整个行业没有什么动力去推动改革。
|
||||||
|
|
||||||
|
但是在2009年的时候,谷歌的两位工程师Mike Belshe和Roberto Peon提出了一个HTTP/1.1的替代方案,叫做SPDY,谐音就是speedy,也就是速度的意思。SPDY起初并没有要替代并成为HTTP/1.1的升级版,但是由于它更适应现代Web应用的开发需求,到2012年,SPDY已经迅速得到了主流浏览器如Chrome、火狐和友朋的支持,而且相关的互联网公司如Google、Facebook、Twitter的后端服务器和代理也在同一时间做出了支持。
|
||||||
|
|
||||||
|
这时,负责维护HTTP标准的IETF坐不住了,从2012年到2015年间开始定义了一系列的对HTTP/2的改进,主要包括了:通过完整的请求和响应的多路复用,减少延迟;通过压缩HTTP头字段来最小化协议开销;增加对“请求优先级”和对“服务器推送”的支持。
|
||||||
|
|
||||||
|
在2015年HTTP/2正式推出后,SPDY也就退出历史舞台了。当然SPDY和HTTP/2并不是竞争的关系,相反SPDY在整个过程中体现了排头兵和“小白鼠”的精神,在无数次实验中,检验每个优化概念在具体实施中的表现和效果。IETF也本着开放的态度,将SPDY中很多的元素纳入了最终的标准。那HTTP/2为了实现上述功能都做了哪些改变呢?下面,我们就具体来看看。
|
||||||
|
|
||||||
|
HTTP/2.0的特点
|
||||||
|
|
||||||
|
在HTTP/2中,最核心的概念就是二进制帧层(binary framing layer),它规定了HTTP消息如何在客户端和服务器之间封装和传输。
|
||||||
|
|
||||||
|
在了解它之前,我们要先了解几个概念,就是流、消息和帧。流是建立连接中的双向字节流,可以携带一条或多条消息。消息是映射到逻辑请求或响应消息的完整帧序列。帧是HTTP/2中最小的通信单元,每个单元都包含一个帧头,它标识帧所属的流。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
总的来说,二进制帧层中的“层”,指的是socket和对外暴露的HTTP接口之间的部分。HTTP/2将HTTP/1.x协议通信明文中的换行改为了二进制编码帧的分段,然后将分段映射到指定的流的消息中,所有这些消息都可以在单个TCP连接中复用。这是支持HTTP/2协议提供的所有其他功能和性能优化的基础。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这样的设计使得客户端和服务器都必须使用新的二进制编码机制来相互解析,也就是说HTTP/1.x客户端无法理解HTTP/2的服务器,反之亦然。这也就是我们之前说的导致HTTP/1.1裹足不前的原因之一。下面我们就具体看看 HTTP/2 具备哪些新特性。
|
||||||
|
|
||||||
|
请求和响应多路复用
|
||||||
|
|
||||||
|
对于HTTP/1.x,每个连接一次只能传递一个响应,如果客户端希望发出多个并行请求以提高性能,则必须使用多个TCP连接。这样不仅会导致前端阻塞,而且还会造成底层TCP连接的低效使用。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
HTTP/2中新的二进制帧层消除了这些限制,而且因为HTTP消息被分解为了独立的帧,所以它们之间可以进行交织,然后在另一端重新组合来实现完整的请求和响应复用。在上图中,我们可以看到同一连接中的多个流,客户端正在向服务器传输流5的DATA帧,而服务器正在向客户端传输流1和3交织的帧序列。因此,有三个并行流正在传输。
|
||||||
|
|
||||||
|
这种方式可以带来一系列的好处:
|
||||||
|
|
||||||
|
|
||||||
|
它可以避免阻塞,允许多个请求并行交错和响应;
|
||||||
|
它可以使用单个连接并行传递多个请求和响应,删除前面我们说过的不必要的HTTP/1.x解决方案,如连接文件、图像精灵和域碎片;
|
||||||
|
通过消除不必要的延迟并提高可用网络容量的利用率,降低页面加载时间。
|
||||||
|
|
||||||
|
|
||||||
|
标头压缩
|
||||||
|
|
||||||
|
每个HTTP传输都包含一组标头,用于描述传输的资源及其属性。在HTTP/1.x中,这类元数据是纯文本的形式,每次传输会增加500~800字节的开销,而如果使用的是HTTP cookie,有时还会增加到上千字节。
|
||||||
|
|
||||||
|
为了减少这样的开销并提高性能,HTTP/2使用HPACK压缩格式压缩请求和响应标头的数据,该格式使用了两种看上去很简单但强大的技术:一是它允许通过静态霍夫曼代码对传输的头部字段进行编码,从而减小了它们各自传输的大小;二是它要求客户端和服务器维护和更新先前看到的标头字段的索引列表(即建立共享压缩上下文),可以作为更加高效编码的参考。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
HPACK有静态和动态两个表。静态的是一些常用的标头元素;动态表开始是空的,后面根据实际的请求来增加。如果想更深入理解霍夫曼算法的同学,也可以参考隔壁班黄清昊老师算法课中的《哈夫曼树:HTTP2.0是如何更快传输协议头的?》。
|
||||||
|
|
||||||
|
请求优先级
|
||||||
|
|
||||||
|
因为上面我们看到,HTTP/2中传递的信息可以被分割成多个单独的帧,并且允许来自多个流的帧被复用,那么考虑到客户和服务器端对帧进行交叉和传递的顺序,HTTP/2标准允许每个流定义1~256之间的权重和依赖性。
|
||||||
|
|
||||||
|
这里面,流的依赖性和权重是一个“优先级树”型的结构,这个树表达了客户端希望如何接收响应。反过来,服务器可以通过控制CPU、内存和其他资源的分配,使用这些信息来确定流处理的优先级,并且一旦响应数据可用,就可以分配带宽,以最优的方式向客户端传递高优先级响应。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
比如在上面的例1中,流A和流B平级,A的权重为8,B的权重为4,因此,A应分配2/3可用资源,B应获得剩余的1/3。在例2中,C依赖于D,因此,D应该在C之前获得全部资源分配。以此类推,在第3个例子中,D应先于C获得全部资源分配,C应先于A和B获得全部资源分配,A应获得可用资源的2/3,B应获得剩余的1/3。
|
||||||
|
|
||||||
|
服务器推送
|
||||||
|
|
||||||
|
HTTP/2的另一个强大的新特性是服务器能够为单个客户端请求发送多个响应。也就是说,除了对原始请求的响应之外,服务器还可以向客户端推送额外的资源,而客户端不必特意请求每个资源。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
为什么我们在浏览器中需要这样的机制呢?因为一个典型的Web应用程序可能就会由几十个资源组成,因此,不需等待客户端请求就将资源推送到客户端,可以消除额外的延迟。
|
||||||
|
|
||||||
|
其实我们前面说过的,通过数据URI将CSS或JavaScript资源手动内联到文档中得到的结果就类似服务器推送的结果了,但服务器推送具有几个额外的优势:
|
||||||
|
|
||||||
|
|
||||||
|
客户端可以缓存推送的资源;
|
||||||
|
推送的资源可以跨不同页面重用;
|
||||||
|
推送的资源可以与其他资源一起复用;
|
||||||
|
推送的资源可以由服务器确定优先级;
|
||||||
|
客户端也可以拒绝推送的资源。
|
||||||
|
|
||||||
|
|
||||||
|
在使用服务器推送时,需要注意的是基于前端浏览器的安全限制,要求推送资源必须遵守同源策略。同源是个术语,这里的源是来源,也就是说服务器必须保障提供的内容具有权威性。
|
||||||
|
|
||||||
|
HTTP/2.0的优化
|
||||||
|
|
||||||
|
通过HTTP/2上述的优势,我们可以看到,与其说我们需要优化,不如说针对HTTP/1.1可以反优化。也就是说我们前面说的域名分片、捆绑资源、CSS精灵、内联资源都是可以省掉的了,但是这不代表说我们不需要任何的优化了。
|
||||||
|
|
||||||
|
因为有一些优化是不基于任何HTTP版本都可以采用的。从应用实现的角度来看,就包含了客户端的缓存、资源的压缩、减少无用的请求字节、并行请求和返回的处理。基于技术重要性和产生的影响,下面我们可以重点看看缓存和压缩。
|
||||||
|
|
||||||
|
客户端的缓存
|
||||||
|
|
||||||
|
首先,我们可以说最快的网络请求是没有请求。所以我们可以缓存先前下载数据,这样客户端在后续访问中可以使用资源的本地副本,从而消除请求。我们可以通过 Cache-Control 标头指定资源的缓存生存期,再通过 Last Modified 和 ETag 标头提供验证机制。
|
||||||
|
|
||||||
|
资源的压缩
|
||||||
|
|
||||||
|
虽然利用本地缓存,客户端可以避免在每个请求中获取重复内容。但是,如果必须提取资源,或者资源已过期、是新的,或者无法缓存,则应以最小字节数传输。压缩常用的方法有 Gzip 和 Brotli 压缩。
|
||||||
|
|
||||||
|
Gzip压缩格式已经有近30年历史了,几乎所有主要浏览器都支持Gzip。它是一种基于Deflate算法的无损算法。Deflate算法本身对输入数据流中的数据块使用的是 LZ77 算法和霍夫曼编码的组合。LZ77算法识别重复的字符串,并将其替换为反向引用,反向引用是指向先前出现的位置的指针,后跟字符串的长度。随后,霍夫曼编码识别了常用的引用,并将其替换为具有较短比特序列的引用。较长的位序列用于表示不常用的引用。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
之后,在2012年,Google推出了一个Zopfli压缩算法,它的优势是可以生成更小的Gzip兼容文件。但相比Deflate/Gzip,它的速度较慢,所以更适合静态压缩。
|
||||||
|
|
||||||
|
再之后,到了2015年,谷歌又推出了Brotli算法和压缩数据格式。与GZip一样,Brotli也是一种基于LZ77算法和霍夫曼编码的无损算法。此外,它使用二阶上下文建模,以类似的速度产生更密集的压缩。上下文建模是一种允许在同一块中对同一字母表使用多个霍夫曼树的功能。Brotli还支持较大的反向引用窗口,并具有静态字典。这些特性有助于提高其作为压缩算法的效率。Brotli现在受到主要服务器、浏览器和托管提供商、中间件(包括阿里云和AWS等)的支持。
|
||||||
|
|
||||||
|
HTTP/3
|
||||||
|
|
||||||
|
说完了HTTP/1和HTTP/2,我们再来看看HTTP/3。HTTP/3的前身,快速UDP互联网连接(QUIC,Quick UDP Internet Connections)是由谷歌于2012年推出的。它不再通过TCP,而是通过用户数据报协议(UDP,User Datagram Protocol)进行传输。
|
||||||
|
|
||||||
|
同为传输层的协议,相比较TCP,UDP更加简便和快速。你可能会觉得UDP是不可靠的,因为它缺少稳定性。而QUIC针对这个问题对UDP做了改良,让它可以像TCP一样避免丢包问题,让传输的内容使命必达。从传输的对象来看,QUIC传输的是包和帧,一个包里可以有多个帧。从安全层面来看,QUIC内置了TLS协议,所以不需要额外再添加相关的功能。另外一个变化是,头部压缩算法从HTTP/2中的HPACK升级成了HTTP/3中的QPACK来解决头部阻塞问题。
|
||||||
|
|
||||||
|
QUIC被谷歌用于YouTube等网站。虽然QUIC从未获得过广泛使用,但它促进了HTTP/3标准委员会的团队工作,并帮助指导委员会利用UDP作为协议的基础层。另外,它还将SSL/TLS安全性内置到了QUIC协议中,并引领HTTP/3将其也内置到协议。在HTTP/3开发过程中,其他公司已经开始实施QUIC协议。其中一个值得注意的例子是Cloudflare。最终,HTTP/3在今年(2022年)6月正式定版了。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过这一讲,我们看到了HTTP的前世今生,以及在这个过程中,对前端开发的影响。我们发现,HTTP/2推出后的接受度并不高,之后推出的HTTP/3似乎也有待市场的验证。所以很多开发者认为,这些技术似乎没有在真正意义上发挥到作用。
|
||||||
|
|
||||||
|
我认为有些技术是厚积薄发的,比如我们看到很多大厂像苹果、谷歌等一直走在前面去实践。主要是他们的业务可以形成规模化,所以这些性能优化可以给他们带来可观的影响,但是对于小一些的应用,似乎这些影响就没有这么明显了。
|
||||||
|
|
||||||
|
但是我认为这些会随着时间而改变。这有点像5G一样,似乎5G还没有火起来,6G的标准已经在制定了。它虽然在民间应用上较少,但在2B业务上已经有很大的应用市场了。而且有些技术是要随着配套的相关技术,如基础设施、算力和人们习惯的改变而产生规模效应的。
|
||||||
|
|
||||||
|
随着流媒体、元宇宙和Web3.0概念的兴起,我们对网络的要求只会变得更高,所以结合我们前面说的并发、并行的发展,未来某一天一定会引爆这些技术的普及。但是如果我们不能忍受它爆发前的寂寞,因此而放弃理解和尝试,可能就会在它们到来时错失把握机会的能力。所以我认为虽然HTTP/2和HTTP/3可能不是前端开发中实际用到的技术,但是对前端开发和服务器端如Node.js的开发还是有着深刻影响,值得我们学习和了解。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
前面,我们说HTTP/3使用QPACK替代了HTTP/2的HPACK,而起到了性能优化的效果,那么你知道它是通过改进哪些算法来实现性能优化吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
173
专栏/JavaScript进阶实战课/31安全:JS代码和程序都需要注意哪些安全问题?.md
Normal file
173
专栏/JavaScript进阶实战课/31安全:JS代码和程序都需要注意哪些安全问题?.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
31 安全:JS代码和程序都需要注意哪些安全问题?
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
对于大多Web应用来说,我们的程序所提供的功能是对外公开的,这给我们带来了流量的同时,也给我们的系统带来了风险。而且越是关注度高的应用,就越有可能遭到攻击,所以安全是任何应用都要重视的一个话题。
|
||||||
|
|
||||||
|
那么今天,我们来看一下在JavaScript中需要做的安全考虑。这里就包含了跨站脚本攻击(XSS)、跨站请求伪造(CSRF)和XXE漏洞。下面,我们来一一看一下这些问题,总结下相关的安全解决方法。
|
||||||
|
|
||||||
|
跨站脚本攻击
|
||||||
|
|
||||||
|
跨站脚本攻击是一种非常常见的攻击。顾名思义,它指的就是通过来自跨网站的脚本发起的攻击。为了降低出现XSS漏洞的可能性,有一条重要的开发原则是我们应该禁止任何用户创建的未经过处理的数据传递到DOM中。而当用户创建的数据必须传递到DOM中时,应该尽量以字符串的形式传递。
|
||||||
|
|
||||||
|
类字符串检查
|
||||||
|
|
||||||
|
我们可以通过多种方式在客户端和服务器上对数据进行检查。其中一种方法是利用JavaScript中的内置函数 JSON.parse(),它可以将文本转换为JSON对象,因此将数字和字符串与转换后的内容做对比。如果一致,就可以通过检查,但函数等复杂的数据类型的对比是会失败的,因为它们不符合与JSON兼容的格式。
|
||||||
|
|
||||||
|
var isStringLike = function(val) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(val)) === val;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('not string-like');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
字符串净化
|
||||||
|
|
||||||
|
另外需要注意的是,字符串/类字符串虽然不是DOM本身,但仍然可以被解释或转换为DOM。为了避免这种情况,我们必须确保DOM会将其直译为字符串或类字符串,而不作转换。
|
||||||
|
|
||||||
|
因此在使用 DOM API 时,需要注意的是,任何将文本转换为DOM或脚本的行为都是潜在的XSS 攻击。我们应该尽量避免使用 innerHTML、DOMParser、Blob 和 SVG 这些接口。以 innerHTML 为例,我们最好使用 innerText 而不是 innerHTML 来注入 DOM,因为innerText 会做净化处理,将任何看起来像HTML标签的内容表示为字符串。
|
||||||
|
|
||||||
|
var userInput = '<strong>hello, world!</strong>';
|
||||||
|
var div = document.querySelector('#userComment');
|
||||||
|
div.innerText = userInput; // 这里的标签被解释成字符串
|
||||||
|
|
||||||
|
|
||||||
|
HTML实体编码
|
||||||
|
|
||||||
|
另外一种防御措施是,对用户提供的数据中存在的所有HTML标记执行HTML实体转义。实体编码允许某些特定的字符在浏览器中显示,但不能将其解释为JavaScript来执行。比如标签<>对应的编码就是 & + lt; 和 & + gt;。
|
||||||
|
|
||||||
|
CSS净化
|
||||||
|
|
||||||
|
CSS 的净化包括了净化任何 HTTP 相关的 CSS 属性,或者只允许用户更改指定的 CSS 字段,再或者干脆禁止用户上传 CSS 等。因为相关的属性,比如 background:url,可能会导致黑客远程改变页面的展示。
|
||||||
|
|
||||||
|
#bankMember[status="vip"] {
|
||||||
|
background:url("https://www.hacker.com/incomes?status=vip");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
内容安全策略
|
||||||
|
|
||||||
|
内容安全策略(CSP)是一个安全配置工具。CSP 可以允许我们以白名单的方式列出允许动态加载脚本的网站。实现 CSP 的方式很简单。在后端的话,可以通过加Content-Security-Policy的标头来实现;如果是在前端的话,则可以通过元标签实现。
|
||||||
|
|
||||||
|
Content-Security-Policy: script-src "self" https://api.example.com.
|
||||||
|
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="script-src https://www.example.com;">
|
||||||
|
|
||||||
|
|
||||||
|
另外,默认的情况下,CSP 可以禁止任何形式的内联脚本的执行。在使用 CSP 的时候,要注意不要使用eval(),或者类似 eval() 的字符串。eval() 的参数是一个字符串,但如果字符串表示的是一个函数表达式,eval() 会对表达式进行求值。如果参数表示一个或多个 JavaScript 语句,那么 eval() 也会执行这些语句。
|
||||||
|
|
||||||
|
eval("17+9") // 26
|
||||||
|
var countdownTimer = function(mins, msg) {
|
||||||
|
setTimeout(`console.log(${msg});`, mins * 60 * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
尽量避免的API
|
||||||
|
|
||||||
|
有些 DOM API 是要尽量避免使用的。下面我们再看看DOMParser API,它可以将 parseFromString 方法中的字符串内容加载到DOM节点中。对于从服务器端加载结构化的 DOM,这种方式可能很方便,但是却有安全的隐患。而通过 document.createElement() 和 document.appendChild() 可以降低风险,虽然使用这两种方法增加了人工成本,但是可控性会更高。因为在这种情况下,DOM 的结构和标签名称的控制权会在开发者自己手中,而负载只负责内容。
|
||||||
|
|
||||||
|
var parser = new DOMParser();
|
||||||
|
var html = parser.parseFromString('<script>alert("hi");</script>`);
|
||||||
|
|
||||||
|
|
||||||
|
Blob 和 SVG 的 API 也是需要注意的接口,因为它们存储任意数据,并且能够执行代码,所以很容易成为污点汇聚点(sinks)。Blob 可以以多种格式存储数据,而 base64 的 blob 可以存储任意数据。可缩放矢量图形(SVG)非常适合在多种设备上显示一致的图像,但由于它依赖于允许脚本执行的XML规范,SVG 可以启动任何类型的 JavaScript加载,因此使用的风险要大于其他类型的可视化图像。
|
||||||
|
|
||||||
|
另外,即使是将不带标签的字符串注入DOM时,要确保脚本不会趁虚而入也很难。比如下面的例子就可以绕开标签或单双引号的检查,通过冒号和字符串方法执行弹窗脚本,显示”XSS”。
|
||||||
|
|
||||||
|
<a href="javascript:alert(String.fromCharCode(88,83,83))">click me</a>
|
||||||
|
|
||||||
|
|
||||||
|
跨站请求伪造
|
||||||
|
|
||||||
|
以上就是针对跨站脚本攻击的一些防御方案,接下来我们看看跨站请求伪造(CSRF),这也是另外一个需要注意的安全风险。
|
||||||
|
|
||||||
|
请求来源检查
|
||||||
|
|
||||||
|
因为CSRF的请求是来自应用以外的,所以我们可以通过检查请求源来减少相关风险。在HTTP中,有两个标头可以帮助我们检查请求的源头,它们分别是 Referer 和 Origin。这两个标头不能通过JavaScript在浏览器中修改,所以它们的使用可以大大降低CSRF攻击。Origin 标头只在 HTTP POST 请求中发送,它表明的就是请求的来源,和Referer不同,这个标头也在 HTTPS 请求中存在。
|
||||||
|
|
||||||
|
Origin: https://www.example.com:80
|
||||||
|
|
||||||
|
|
||||||
|
Referer标头也是表示请求来源。除非设置成 rel=noreferer,否则它的显示如下:
|
||||||
|
|
||||||
|
Referer: https://www.example.com:80
|
||||||
|
|
||||||
|
|
||||||
|
如果可以的话,你应该两个都检查。如果两个都没有,那基本可以假设这个请求不是标准的请求并且应该被拒绝。这两个标头是安全的第一道防线,但在有一种情况下,它可能会破防。如果攻击者被加入来源白名单了,特别是当你的网站允许用户生成内容,这时可能就需要额外的安全策略来防止类似的攻击了。
|
||||||
|
|
||||||
|
使用CSRF令牌
|
||||||
|
|
||||||
|
使用CSRF令牌也是防止跨站请求伪造的方法之一。它的实现也很简单,服务器端发送一个令牌给到客户端。
|
||||||
|
|
||||||
|
这个令牌是通过很低概率出现哈希冲突的加密算法生成的,也就是说生成两个一样的令牌的概率非常低。这个令牌可以随时重新生成,但通常是在每次会话中生成一次,每一次请求会把这个令牌通过表单或Ajax回传。当请求到达服务器端的时候,这个令牌会被验证,验证的内容会包含是否过期、真实性、是否被篡改等等。因为这个令牌对于每个会话和用户来讲都是唯一的,所以在使用令牌的情况下,CSRF的攻击可能性会大大降低。
|
||||||
|
|
||||||
|
下面我们再来看看无状态的令牌。在过去,特别是 REST API 兴起之前,很多服务器会保留一个用户已连接的记录,所以服务器可以为客户管理令牌。但是在现代网络应用中,无状态往往是 API 设计的一个先决条件。通过加密,CSRF令牌可以轻易地被加到无状态的 API 中。像身份认证令牌一样,CSRF 令牌包含一个用户的唯一识别,一个时间戳,一个密钥只在服务器端的随机密码。这样的一个无状态令牌,不仅更实用,而且也会减少服务器资源的使用,降低会话管理所带来的成本。
|
||||||
|
|
||||||
|
无状态的GET请求
|
||||||
|
|
||||||
|
因为通常最容易发布的CSRF攻击是通过HTTP GET请求,所以正确地设计API的结构可以降低这样的风险。HTTP GET请求不应该存储或修改任何的服务器端状态,这样做会使未来的HTTP GET请求或修改引起CSRF攻击。
|
||||||
|
|
||||||
|
// GET
|
||||||
|
var user = function(request, response) {
|
||||||
|
getUserById(request.query.id).then((user) => {
|
||||||
|
if (request.query.updates) { user.update(request.updates); }
|
||||||
|
return response.json(user);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
参考代码示例,第一个API把两个事务合并成了一个请求+一个可选的更新,第二个API把获取和更新分成了GET和POST两个请求。第一个API很有可能被CSRF攻击者利用;而第二个API,虽然也可能被攻击,但至少可以屏蔽掉链接、图片或其它HTTP GET风格的攻击。
|
||||||
|
|
||||||
|
// GET
|
||||||
|
var getUser = function(request, response) {
|
||||||
|
getUserById(request.query.id).then((user) => {
|
||||||
|
return response.json(user);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// POST
|
||||||
|
var updateUser = function(request, response) {
|
||||||
|
getUserById(request.query.id).then((user) => {
|
||||||
|
user.update(request.updates).then((updated) => {
|
||||||
|
if (!updated) { return response.sendStatus(400); }
|
||||||
|
return response.sendStatus(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
泛系统的CSRF防御
|
||||||
|
|
||||||
|
根据木桶原则,一个系统往往是最弱的环节影响了这个系统的安全性。所以我们需要注意的是,如何搭建一个泛系统的CSRF防御。大多的现代服务器都允许创建一个在执行任何逻辑前,在所有访问中都可以路由到的中间件。
|
||||||
|
|
||||||
|
这样的一个中间件检查我们前面说的 Origin/Referer 是否正确,CSRF 令牌是否有效。如果没有通过这一系列的检查,这个请求就应该中断。只有通过检查的请求,才可以继续后续的执行。因为这个中间件依赖一个客户端对每一个请求传一个 CSRF 令牌到服务器端,作为优化,这个检查也可以复制到前端。比如可以用包含令牌的代理模式来代替 XHR 默认的行为,或者也可以写一个包装器把 XHR 和令牌包装在一个工具里来做到复用。
|
||||||
|
|
||||||
|
XXE漏洞
|
||||||
|
|
||||||
|
XXE是XML外部实体注入(XML External Entity)的意思。防止这个攻击的方法相对简单,我们可以通过在XML解析器中禁止外部实体的方式来防御这种攻击。
|
||||||
|
|
||||||
|
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||||
|
|
||||||
|
|
||||||
|
对基于Java语言的XML解析器,OWASP把XXE标记为尤为危险;而对于其它语言来说,XML外部实体默认可能就是禁止的。但为了安全起见,无论使用什么语言,最好还是根据语言提供的API文档找到相关的默认选项,来看是否需要做相关安全处理。
|
||||||
|
|
||||||
|
而且如果有可能的话,使用JSON来替代XML也是很好的选择。JSON相对比XML更轻盈、更灵活,能使负载更加快速和简便。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
今天,我们看到了一些Web中常见的漏洞和攻击。网络和信息安全不仅是影响业务,更是对用户数字资产和隐私的一种保护,所以在安全方面,真的是投入再多的精力都不过分。这节课我们只是站在Web应用的角度分析了一些常见的漏洞,实际上安全是一个更大的值得系统化思考的问题。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
今天,我们提到了XML和SVG都是基于XML Schema 的,XML我们大多可以通过JSON来代替,那么你知道SVG可以通过什么来代替吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
96
专栏/JavaScript进阶实战课/32测试(一):开发到重构中的测试.md
Normal file
96
专栏/JavaScript进阶实战课/32测试(一):开发到重构中的测试.md
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
32 测试(一):开发到重构中的测试
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在软件工程中有很多思想,其中被谈及最多的就是敏捷交付了。虽然人们一直讲敏捷,快速交付,但是往往忽略了敏捷不代表牺牲质量。而问题是,在强调开发速度的同时,交付质量在很多时候容易被忽视。于是,测试驱动开发(TDD)的思想就诞生了,虽然后面又出现了面向用户的行为驱动测试,但是对于开发者而言,TDD仍然是和敏捷最成熟的搭配开发模式。
|
||||||
|
|
||||||
|
今天,就让我们来看看测试驱动的开发。
|
||||||
|
|
||||||
|
红绿重构
|
||||||
|
|
||||||
|
测试驱动开发中有个很重要的概念就是红绿重构(red/green/refactor)循环,在一个循环中,有三个步骤:
|
||||||
|
|
||||||
|
|
||||||
|
第1步是创建一个失败的测试,因为这时我们还没有相关的功能被开发出来,所以测试肯定是失败的,这是红色的部分;
|
||||||
|
第2步是写出恰好能通过测试的代码,这个就是绿色的部分;
|
||||||
|
第3步是重构,在这一步中我们会看之前的代码是否有可以优化的部分来进行优化。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
乍一看,你可能觉得这似乎是反直觉的。TDD和传统的开发相反,在传统的开发中,按道理来说我们应该先做第3步,设计出“优雅”的代码结构;然后再做第2步,写好代码;最后再做第1步,测试基于我们的设计开发出来的代码。
|
||||||
|
|
||||||
|
那为什么在TDD中我们要反其道而行呢?这其中的原因是我们的测试用例并不是凭空想象的,它是根据我们的用户故事和验收条件创建的。它的目的是让我们从第1步开发前就清楚开发的目的和想要得到的结果,然后第2步才是写出满足我们目标的代码,也就是一步步实现目标的过程。这时,如果我们的代码写好了,那它自然而然就通过测试了,从而避免积累了大量的问题后,才发现之前写的程序有问题。
|
||||||
|
|
||||||
|
那为什么重构是第3步呢?因为对于我们大多数的项目,特别是业务驱动的项目来说,时间就是金钱,效率就是生命。所以能按时开发出可以运行的代码比开发出优雅的代码更重要。因为可以运行的代码是用户可以直接获益的,而优雅的代码很多时候是程序员更关注的。
|
||||||
|
|
||||||
|
那这里你可能会担心,这样一再追求结果的开发模式,会不会导致长期过度以结果为导向,而影响了代码的质量,造成长期的技术债?不用担心,这正是“红绿重构循环”中的循环要解决的问题。重构不是在技术债的积累爆发后进行的,而是每次的开发周期中的一个环节。也就是说它还是存在的,并且一直迭代。这样就在避免了开始的过度设计的同时,也保证了持续的优化和迭代。
|
||||||
|
|
||||||
|
重构的目的一般是通过对代码结构的调整和去重来优化我们的软件设计,让我们的代码更容易理解。所以它的目的虽然不是让我们更快地交付最小化可行产品(MVP),但是它可以让我们的代码变得更易懂和容易维护,如我们在函数式编程中曾经提到的,毕竟我们写的代码更多是给人读的,而不是给机器读的。
|
||||||
|
|
||||||
|
测试TDD还是行为BDD驱动开发
|
||||||
|
|
||||||
|
我们知道除了TDD外,另外一个概念是 BDD开发。基本上,BDD和TDD的测试流程很相似,它们的不同主要体现在测试的角度上。
|
||||||
|
|
||||||
|
对于TDD来说,测试用例是用代码写的,面向的对象是程序员。而BDD的测试用例一般是业务人员或用户写的,所以用例可以是我们平时用的中英文语言,而不是代码。
|
||||||
|
|
||||||
|
TDD核心关注的是最小单元的测试,也就是单元测试(unit test),其次是集成(integration test)和端到端测试(end to end test)。因为BDD的测试已经到行为层面了,所以这样的测试一般要在端到端的基础上才能运行,也就是说如果我们要在BDD中也用红绿重构的话,它里面也会有更小的TDD单元测试的红绿重构循环。
|
||||||
|
|
||||||
|
这也就引出了下一个问题,红绿重构可以是循环嵌套的。比如,如下图所示,我们在左上角编写了一个失败的测试,也就是红色状态的测试。从这个状态出发,有3种可能性。一是我们可以顺着第一个失败的测试编写下一个失败测试用例;二是我们可以顺着这个失败用例写出相关的程序来通过测试;三是我们没办法通过当前这个测试,这时,我们需要下一层的循环嵌套,在里面再创建一个红绿重构的流程,继续创建新的失败测试。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
对于处于绿色状态的测试而言,也可能出现两种场景。第1种场景是重构;第2种场景是完成调用,离开循环,也就代表测试已经通过且没有需要重构的代码。最后,对于处于蓝色的重构状态来说,我们可以重构,基于测试结果,我们可以继续重构,或者完成代码测试,并离开循环。
|
||||||
|
|
||||||
|
当所有测试都完成后,所有的流程都会离开循环。如果我们处于内部循环,这意味着我们进入外部循环的绿色阶段。如果我们处于外部循环中,并且我们想不出更多的测试要写,那么整体的测试就完成了。
|
||||||
|
|
||||||
|
这个过程中值得注意的是,只有在绿色测试通过之后,考虑重构的时候,才会再创建一个新的红色测试。如果不需要重构,就表示没有更多工作要做。比如最近刚刚优化过一次,没有再需要重构的,那就可以直接退出循环了。
|
||||||
|
|
||||||
|
在嵌套的测试中,我们把外面一层的测试叫做高级测试,把里面一层叫做低级测试。如果上面的理论还有些抽象的话,我们可以具体到一个TDD循环嵌套的例子来看看。假设我们有一个用户登录的模块,在这个模块中,我们可以有如下里外两层的测试。
|
||||||
|
|
||||||
|
|
||||||
|
外部循环失败测试用例可以是:客户可以登录。
|
||||||
|
|
||||||
|
|
||||||
|
内部循环的失败测试用例可以是:登录路径 /login 返回200响应;
|
||||||
|
为了通过低级测试,就要编写和测试路由的代码,直到测试状态转为绿色;
|
||||||
|
通过低级测试,状态转绿后,可以开始重构内部循环中的代码;
|
||||||
|
此时外部循环测试可能仍然失败,我们发现缺少一个内部循环的测试,因此我们又编写了一个新的内部循环的失败测试:短信和社交媒体登录验证通过,位于 /login-post 的表单post路由应该重定向到 /personal-center;
|
||||||
|
为了通过新的测试,我们编写了处理成功通过短信/社交媒体验证并返回登录的个人主页的代码;
|
||||||
|
这个新的内部循环测试也通过了,所以我们现在可以重构在内部循环上编写的代码。
|
||||||
|
|
||||||
|
现在,内部循环的低级测试和外部循环的高级测试都通过了。
|
||||||
|
当外部循环测试中的所有用例都通过变为绿色后,我们就可以重构外部循环的代码了。
|
||||||
|
|
||||||
|
|
||||||
|
延伸:除了单元,还有哪些测试类型?
|
||||||
|
|
||||||
|
前面,我们说过对于TDD来说,最核心的是单元测试。因为这个时候我们主要关注的是代码的细节和应用自身的功能实现,所以这时的开发和测试可能都是本地的。那么什么可以被看做是一个单元呢?
|
||||||
|
|
||||||
|
在很多语言里,单元指的是类或函数。而在JavaScript中,单元既可以是类或函数,也可以是模块或包。无论我们如何定义单元测试,重点是这类测试关注的是每个单元中功能输入和输出的行为,以及过程中创建的对象。在这个过程中,我们也可能会对上下游系统有依赖,不过这种依赖通常都会通过Mock和Stub的方式模拟代替。通过这种方式,可以将系统间的测试延后。
|
||||||
|
|
||||||
|
当前端的自身测试都通过之后,我们会继续集成测试。集成测试通常是一对一的和自己系统相关的上下游的系统间测试。这个时候的上下游系统,可能依然是自己系统的一部分,特别是在大前端的概念下,多个前端可能都会共享一个后端的服务器提供的内容,相关的内容服务可能会通过BFF,也就是为前端提供的后端层(backend for frontend)来提供的。
|
||||||
|
|
||||||
|
之后的测试通过后,才会是和外部系统的端到端测试。在端到端测试中,我们可能就会和更多层的系统联调测试。比如一个电商功能,可能我们在前端下的订单,会先调用支付系统的接口完成支付,同时发送电商下单请求,支付成功后收到结果,再发送给电商系统通知发货,电商系统会再通知下游系统发货,将发货通知再返回给前端。这样一个全链路的功能,就需要端到端测试的支持。对于此类测试而言,应用代码应该和其它系统的代码结合。这时,就应该尽量避免使用Mock或Stub来模拟前后端的交互,而是应该强调前端和后端系统之间的接口能实际跑通,也就是基于系统间的集成。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
在很多讲TDD测试的书或文章中,通常会偏概念化,希望通过今天的学习,你能对它有更具象的了解。下一讲,我们会更具象化地展开它的实践。
|
||||||
|
|
||||||
|
围绕JavaScript而产生的测试工具有很多。而且这些工具很多都是以模块化的方式存在的,也就是说一类测试可以用工具A来完成,另外一类测试又可以通过工具B。在下一讲中,我们就会来看一下Jest,相对于其它的测试框架而言,Jest有对不同类型的测试相对更广泛的支持。这也就避免了我们在不同的测试场景之间切换工具的情况。
|
||||||
|
|
||||||
|
除了功能性的测试,还有非功能性测试。非功能性测试又包含了性能测试、安全测试和辅助功能测试等等。这些,我们也会进一步在“测试三部曲”中的第三讲来看。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
我们今天从红绿重构的角度了解了测试驱动的开发,这里我们主要看的是测试的深度(嵌套),除此之外,可能我们也要关注覆盖率和复杂度(圈数),那么你能分享下平时在开发中你是否有测试驱动开发的习惯?通常测试的覆盖率能达到多少呢?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
253
专栏/JavaScript进阶实战课/33测试(二):功能性测试.md
Normal file
253
专栏/JavaScript进阶实战课/33测试(二):功能性测试.md
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
33 测试(二):功能性测试
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在上一讲中,我们通过红绿重构循环对抽象的测试驱动开发(TDD)做了具象化的理解。今天,我们将进一步通过具体的单元测试来掌握这种开发模式的实施。
|
||||||
|
|
||||||
|
测试工具对比
|
||||||
|
|
||||||
|
目前,市面上已经有很多围绕JavaScript产生的第三方测试工具,所以在这里,我们不需要重复造轮子,通过已有的测试框架来帮助我们进行测试就可以了。首先我们可以对比下几个比较流行的框架:Mocha、Jest和Jasmine。
|
||||||
|
|
||||||
|
这三个工具都基于断言函数(assertion functions)来帮助我们增加测试的可读性和可扩展性,也都支持我们了解测试进度和生成最终的测试结果报告,了解代码的覆盖率。除此之外,Jest更能支持Mock/Stub的测试,同时也可以生成快照来对比前后的测试结果,它也支持多线程。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
最小化的单元测试
|
||||||
|
|
||||||
|
我们可以看一个简单的例子。首先我们要安装Jest,这要基于Node和NPM,你可以在Terminal通过运行下面的命令来看是否已经安装了Node和NPM。如果返回的是相关的版本信息,那么证明这两个工具已经存在了。
|
||||||
|
|
||||||
|
node -v
|
||||||
|
npm -v
|
||||||
|
|
||||||
|
|
||||||
|
下一步,我们需要再安装Jest,在Terminal,我们可以通过下面这行命令安装Jest。global的flag可以允许我们从命令行的客户端如 Terminal 或 Command Prompt 直接运行Jest测试。
|
||||||
|
|
||||||
|
npm install jest --global
|
||||||
|
|
||||||
|
|
||||||
|
下面,假设我们想要写一个斐波那契数列函数,按照测试驱动的思想,我们先来创建一个fib.test.js的测试文件,里面包含如下的测试用例:如果我们输入7,斐波那契数列结果应该是13。
|
||||||
|
|
||||||
|
test('7的斐波那契结果是13', () => {
|
||||||
|
expect(fib(7, 0, 1)).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
通过下面的指令,我们可以运行上面的测试脚本:
|
||||||
|
|
||||||
|
jest fib.test.js
|
||||||
|
|
||||||
|
|
||||||
|
这时,我们如果运行上述的测试,结果肯定是报错。因为在这个时候,我们还没创建斐波那契数列的函数!所以这一步就是我们红绿重构中的红色部分。
|
||||||
|
|
||||||
|
这时,我们知道为了通过测试,下一步需要创建一个斐波那契的函数。我们可以通过如下的方式创建一个并且保存在fib.js里。在这个文件的尾部,我们做了模块化的导出,为的是让我们能够在刚才创建的测试文件中做导入和引用。
|
||||||
|
|
||||||
|
function fib(n, lastlast, last){
|
||||||
|
if (n == 0) {
|
||||||
|
return lastlast;
|
||||||
|
}
|
||||||
|
if (n == 1) {
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
return fib(n-1, last, lastlast + last);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = fib;
|
||||||
|
|
||||||
|
|
||||||
|
之后,我们可以在前面的用例中导入斐波那契函数。
|
||||||
|
|
||||||
|
var fib = require('./fib');
|
||||||
|
|
||||||
|
test('7的斐波那契结果是13', () => {
|
||||||
|
expect(fib(7, 0, 1)).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
当我们再次通过之前的指令运行上面的文件时,就可以看到通过的结果。也就是到了红绿重构中的绿色。因为这是一个相对较为简单的测试,我们不需要重构,所以当执行到这里时,我们就可以当做测试完成了。
|
||||||
|
|
||||||
|
数据值类型的匹配
|
||||||
|
|
||||||
|
在数据类型的一讲中,我们讲过了JavaScript赋值中的一些常见的坑,比如值的比较和严格比较所返回的结果是不同的,以及除了布尔值之外,可能会返回否值的数据类型。所以在测试的时候,我们也应该注意我们期待的结果和实际结果是不是匹配的。Jest就自带了很多的内置方法来帮助我们做数据类型的匹配。
|
||||||
|
|
||||||
|
下面我们可以通过两个例子来看看。在第一个例子中,我们可以看到当我们使用toEqual来做比较的时候,undefined就被忽略了,所以测试可以通过,但当我们使用toStrictEqual的时候,则可以看到严格比较的结果,测试的结果就是失败。在第二个例子中,我们可以看到因为数字的值可以是NaN,它是falsy的值,所以测试的结果是通过。
|
||||||
|
|
||||||
|
// 例子1
|
||||||
|
test('check equal', () => {
|
||||||
|
var obj = { a: undefined, b: 2 }
|
||||||
|
expect(obj).toEqual({b: 2});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check strict equal', () => {
|
||||||
|
var obj = { a: undefined, b: 2 }
|
||||||
|
expect(obj).toStrictEqual({b: 2});
|
||||||
|
});
|
||||||
|
|
||||||
|
//例子2
|
||||||
|
test('check falsy', () => {
|
||||||
|
var num = NaN;
|
||||||
|
expect(num).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
我们在前面一个小节斐波那契的例子中用到的 toBe(),是代表比较还是严格比较的结果呢?实际上都不是,toBe() 用的是 Object.is。除了toBeFasly,其它的测试真值的方法还有toBeNull()、toBeUndefined()、toBeDefined()、toBeTruthy()。同样,在使用的时候,一定要注意它们的实际意义。
|
||||||
|
|
||||||
|
除了严格比较和否值外,另外一个也是我们在数据类型讲到的问题,就是数字中的浮点数丢精问题。针对这个问题,我们也可以看到当我们用 0.1 加 0.2 的时候,我们知道它不是等于 0.3,而是等于 0.30000000000000004(\(0.3+4\\times10^{-17}\))。所以 expect(0.1+0.2).toBe(0.3) 的结果是失败的,而如果我们使用toBeCloseTo()的话,则可以看到接近的结果是可以通过测试的。
|
||||||
|
|
||||||
|
除了对比接近的数字外,Jest还有toBeGreaterThan()、toBeGreaterThanOrEqual(),toBeLessThan()和toBeLessThanOrEqual()等帮助我们对比大于、大于等于、小于、小于等于的方法,便于我们对数值进行比较。
|
||||||
|
|
||||||
|
test('浮点数相加', () => {
|
||||||
|
var value = 0.1 + 0.2;
|
||||||
|
expect(value).toBe(0.3); // 失败
|
||||||
|
});
|
||||||
|
|
||||||
|
test('浮点数相加', () => {
|
||||||
|
var value = 0.1 + 0.2;
|
||||||
|
expect(value).toBeCloseTo(0.3); // 通过
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
说完了数字,我们再来看看字符串和数组。在下面的两个例子中,我们可以通过toMatch()用正则表达式在字符串中测试一个词是否存在。类似的,我们可以通过toContain()来看一个元素是否在一个数组当中。
|
||||||
|
|
||||||
|
test('单词里有love', () => {
|
||||||
|
expect('I love animals').toMatch(/love/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('单词里没有hate', () => {
|
||||||
|
expect('I love peace and no war').not.toMatch(/hate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
var nameList = ['Lucy', 'Jessie'];
|
||||||
|
test('Jessie是否在名单里', () => {
|
||||||
|
expect(nameList).toContain('Jessie');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
嵌套结构的测试
|
||||||
|
|
||||||
|
下面,我们再来看看嵌套结构的测试。我们可以把一组测试通过 describe 嵌套在一起。比如我们有一个长方形的类,测试过程可以通过如下的方式嵌套。外面一层,我们描述的是长方形的类;中间一层是长方形面积的计算;内层测试包含了长和宽的设置。除了嵌套结构,我们也可以通过 beforeEach 和 afterEach 来设置在每组测试前后的前置和后置工作。
|
||||||
|
|
||||||
|
describe('Rectangle class', ()=> {
|
||||||
|
describe('area is calculated when', ()=> {
|
||||||
|
test('sets the width', ()=> { ... });
|
||||||
|
test('sets the height', ()=> { ... });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
响应式异步测试
|
||||||
|
|
||||||
|
我们说前端的测试很多是事件驱动的,之前我们在讲异步编程的时候,也说到前端开发离不开异步事件。那么通常测试工具也会对异步调用的测试有相关的支持。还是以Jest为例,就支持了callback、promise/then和我们说过的async/await。下面,就让我们针对每一种模式具体来看看。
|
||||||
|
|
||||||
|
首先,我们先来看看callback。如果我们单纯用callback,会有一个问题,那就是当异步刚刚返回结果,还没等callback的执行,测试就执行了。为了避免这种情况的发生,可以用一个done的函数参数,只有当 done() 的回调执行了之后,才会开始测试。
|
||||||
|
|
||||||
|
test('数据是:价格为21', done => {
|
||||||
|
function callback(error, data) {
|
||||||
|
if (error) {
|
||||||
|
done(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
expect(data).toBe({price:21});
|
||||||
|
done();
|
||||||
|
} catch (error) {
|
||||||
|
done(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData(callback);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
下面,我们再来看看promise/then以及async/await的使用。同样如我们在异步时提到过的,实际上,async/await也是promise/then基语法糖的实现。我们也可以将await与resolve和reject结合起来使用。在下面的例子中,我们可以看到当获取数据后,我们可以通过对比期待的值来得到测试的结果。
|
||||||
|
|
||||||
|
// 例子1:promise then
|
||||||
|
test('数据是:价格为21', () => {
|
||||||
|
return fetchData().then(data => {
|
||||||
|
expect(data).toBe({price:21});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 例子2:async await
|
||||||
|
test('数据是:价格为21', async () => {
|
||||||
|
var data = await fetchData();
|
||||||
|
expect(data).toBe({price:21});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
Mock和Stub测试
|
||||||
|
|
||||||
|
最后,我们再来看看 Mock 和 Stub,但是它俩有啥区别呢?
|
||||||
|
|
||||||
|
其实,Mock 和 Stub 都是采用替换的方式来实现被测试的函数中的依赖关系。它们的区别是 Stub 是手动替代实现的接口,而 Mock 采用的则是函数替代的方式。Mock可以帮助我们模拟带返回值的功能,比如下面的myMock,可以在一系列的调用中,模拟返回结果。
|
||||||
|
|
||||||
|
var myMock = jest.fn();
|
||||||
|
console.log(myMock()); // 返回 undefined
|
||||||
|
|
||||||
|
myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);
|
||||||
|
|
||||||
|
console.log(myMock(), myMock(), myMock(), myMock()); // 返回 10, 'x', true, true
|
||||||
|
|
||||||
|
|
||||||
|
在这里,Jest使用了我们前面在函数式编程中讲过的连续传递样式(CPS),这样做的好处是可以帮助我们尽量避免使用Stub。Stub的实现会有很多手动的工作,而且因为它并不是最终的真实接口,所以手工实现真实接口的复杂逻辑不仅不能保证和实际接口的一致性,还会造成很多额外的开发成本。而使用基于CPS的Mock,可以取代Stub,并且节省模拟过程中的工作量。
|
||||||
|
|
||||||
|
var filterTestFn = jest.fn();
|
||||||
|
|
||||||
|
// 首次返回 `true` ;之后返回 `false`
|
||||||
|
filterTestFn.mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||||
|
|
||||||
|
var result = [11, 12].filter(num => filterTestFn(num));
|
||||||
|
|
||||||
|
console.log(result); // 返回 [11]
|
||||||
|
console.log(filterTestFn.mock.calls[0][0]); // 返回 11
|
||||||
|
console.log(filterTestFn.mock.calls[1][0]); // 返回 12
|
||||||
|
|
||||||
|
|
||||||
|
延伸:UI自动化测试
|
||||||
|
|
||||||
|
在上面的例子中,我们看到的是单元测试。但是,我们只要在前端的使用场景中,几乎离不开UI测试。之前,如果我们想测试UI方面的功能反应,要通过手动的方式点击屏幕上的元素,得到相关的反馈。但是对于开发人员来说,有没有什么自动化的方式呢?
|
||||||
|
|
||||||
|
这就要说到无头浏览器和自动化测试了。无头浏览器(headless browser)指的是不需要显示器的浏览器,它可以帮助我们做前端的自动化测试。
|
||||||
|
|
||||||
|
例如Google就开发了Puppeteer,一个基于Node.js的自动化测试工具,它提供了通过开发者工具协议来控制Chrome的API接口。Puppeteer在默认情况下以无头模式运行,但可以配置为在有头下运行的模式。Puppeteer也可以通过预置或手工配置的方式和Jest结合使用。如果选择预置方式的话,相关的第三方库也可以通过NPM来安装。
|
||||||
|
|
||||||
|
npm install --save-dev jest-puppeteer
|
||||||
|
|
||||||
|
|
||||||
|
安装后,可以在Jest的预置配置中加入 "preset": "jest-puppeteer"。
|
||||||
|
|
||||||
|
{
|
||||||
|
"preset": "jest-puppeteer"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
下面,我们以“极客时间”为例,如果要测试极客时间的标题是否显示正确,我们可以通过如下的测试来实现。这个过程中,我们没有用到显示器,但是程序可以自动访问极客时间的首页,并且检查页面的标题是否和期待的结果一致。
|
||||||
|
|
||||||
|
describe('极客时间', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await page.goto('https://time.geekbang.org/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('标题应该是 "极客时间-轻松学习,高效学习-极客邦"', async () => {
|
||||||
|
await expect(page.title()).resolves.toMatch('Google');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过今天这节课,我们看到了在单元测试中如何实现前一讲中提到的红绿重构。同时,我们也看到了测试的结果是要和断言作对比的,因此在比较不同数据类型返回值的过程中,要特别注意避坑。
|
||||||
|
|
||||||
|
之后,我们看到了在嵌套结构的测试中,如何将相关的测试组合在一起,以及设置相关的前置和后置功能。并且在基于事件驱动的设计变得越来越重要的今天,我们如何在测试中处理异步的响应,在真实的接口和逻辑尚未实现的情况下如何通过Mock来模拟接口的反馈,以及通过CPS来尽量避免Stub的复杂逻辑实现和与真实接口不一致的问题。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
今天我们提到了,Jest对比其它工具有着对快照和多线程的支持,你能想到它们的使用场景和实现方式吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
122
专栏/JavaScript进阶实战课/34测试(三):非功能性测试.md
Normal file
122
专栏/JavaScript进阶实战课/34测试(三):非功能性测试.md
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
34 测试(三):非功能性测试
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
上节课,我们学习了功能类测试。今天,我们来看一下非功能性测试中的性能、安全和辅助功能测试。对于一个Web应用而言,性能测试的复杂程度并不低于后端或端到端的测试。导致前端性能测试复杂度很高的主要原因是,影响Web应用性能的因素有很多,并且很多是应用本身不完全可控的。所以今天,我们重点来看一下关于性能测试,都有哪些指标、影响性能的因素和性能调优的方式。
|
||||||
|
|
||||||
|
性能测试
|
||||||
|
|
||||||
|
在任何测试前,我们都需要对要达到的目标有清晰的认识,针对性能测试也不例外。所以首先,我们要来看看对于Web应用来说,都有哪些性能指标。
|
||||||
|
|
||||||
|
性能指标
|
||||||
|
|
||||||
|
之前,我们说过JS虚机开发中做内存管理和优化的时候,会关注浏览器和应用的流畅度(smoothness)和响应度(responsiveness)。其实,对于JS应用的开发者来说,也可以参考类似的 Rail 指标。Rail(response, amination, idle,load)是响应、动效、空闲和加载这4个单词的缩写。下面,我们来分别看下它们所代表的意义。
|
||||||
|
|
||||||
|
|
||||||
|
关于响应,原则是我们的应用应当在50ms内处理响应,并且在50-100ms内提供一个可见的响应;
|
||||||
|
关于动效,如之前讲过的,在处理渲染时,我们的帧预算是每秒60帧,也就是每帧要控制在16.6ms内渲染完成;
|
||||||
|
关于空闲,无论是在加载模式还是垃圾回收的一讲中,我们都提到过应该最大化地利用空闲时间,但是这里需要注意的是,这个利用也需要一个度,如果利用这个时间需要处理的任务超过了50ms,就会影响我们第一点说的100ms内的响应;
|
||||||
|
关于加载,对于页面的加载而言,我们的应用应该尽量在5秒内完成初次加载,并且在2秒内完成后续页面的加载。
|
||||||
|
|
||||||
|
|
||||||
|
影响性能的因素
|
||||||
|
|
||||||
|
说完了测试指标,我们再来看看影响Web性能的因素。前面,我们说过,影响性能的因素是错综复杂的。从大的种类来看,其中就包括了但不限于网络环境、资源的加载和浏览器渲染等外在因素,以及内存管理、垃圾回收等内在因素。
|
||||||
|
|
||||||
|
面对这么多的因素,如果完全通过手动的方式来做性能测试,会非常耗费时间和精力,而且要把这些测试的结果叠加转换成上述指标,又会是一个繁琐的过程。为了解决这个问题,大多的浏览器都提供了开发者工具帮助我们进行这些测试。以Chrome为例,我们可以用到的就有Lighthouse和Performance Insights两个工具。
|
||||||
|
|
||||||
|
性能测试工具
|
||||||
|
|
||||||
|
首先,我们可以看下Lighthouse。在开发者工具的 Lighthouse 页签下,我们可以选择性能检测的选项。从检测的结果中,我们可以看到之前提到的在渲染和加载中关注的FCP、TTI等指标。同时我们可以看到一些性能诊断的建议,比如减少过多的DOM节点、针对静态资源使用高效的缓存策略。同时,我们可以验证一些已经优化项,比如压缩后的JS和CSS文件。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
但是上述的结果中,我们得到的基本上是一个个的数字,对加载过程中的时间分布没有直观的感受。而通过性能洞察(Performance Insights)的页签,我们可以更直观地基于页面实际的使用场景,让页面加载的过程中产生的耗时在一条时间线上展示出来。这样,我们可以更直接地对一些性能瓶颈做分析判断和优化。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
性能瓶颈排查
|
||||||
|
|
||||||
|
上面我们通过Lighthouse和Performance Insights,可以很好地对外在因素做分析。可是针对一些内存管理和垃圾回收类的问题,就无从知晓了。所以我们可以看到表象,但无法分析后面的根因。这个时候我们就需要对内存管理有深入的分析工具。Chrome同样在开发者工具中提供了相应的分析排查工具。
|
||||||
|
|
||||||
|
首先,我们可以通过更多工具->任务管理进入到内存管理的窗口,在这里,我们可以看到内存列展示的内存使用情况。这时,如果我们右键选择JavaScript Memory,也可以同时加载出JS的内存使用信息。那么Memory和JavaScript Memory这两列有什么区别呢?
|
||||||
|
|
||||||
|
这里的区别是Memory中包含的是我们的页面产生的DOM元素。而JS Memory包含的是JS生成的对象的数量和括号中包含的目前有的存活的对象数量。如果这里的数量过高,则需要进一步排查。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在排查的过程中,我们可以从空间和时间的维度来分析。
|
||||||
|
|
||||||
|
首先从时间的维度,我们可以通过开发者工具中的性能页签(Performance Tab)来做性能分析。在这里,我们可以勾选内存选项,然后选择录制,并且在开始和结束的时候通过点击垃圾桶的标志各做一次垃圾回收来避免来自测试时间段以外的干扰。
|
||||||
|
|
||||||
|
在结果的总览窗口中,heap图表示的是JS的堆,概览下方是计数器窗口。在这里,你可以看到按JS堆、节点和GPU内存等细分的内存使用情况。如果此时我们看到,随着记录的进行,结尾处的JS堆或节点的数量高于开始处,则意味着过程中可能存在着内存泄漏。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
另外通过内存页签(Memory Tab),我们可以通过空间的维度来分析性能瓶颈。对于浏览器而言,只有当DOM节点在页面的DOM树或JavaScript代码中没有被引用时,才会被垃圾回收。当节点从DOM树中被移除,但仍然被一些JavaScript引用时,这些节点就被称为“脱离DOM树的节点”。脱离DOM树的节点是造成内存泄漏的常见原因。
|
||||||
|
|
||||||
|
堆快照是识别分离节点的一种方法。顾名思义,堆快照向我们展示了在快照时页面的JS对象和DOM节点之间的内存分布情况。在内存页签中,我们可以看到JS堆快照的选项,它可以帮助我们了解页面上JS对象和相关DOM节点的分布。
|
||||||
|
|
||||||
|
在获取快照后,在对象类型的筛选器中通过“detached”关键词,我们可以搜索脱离DOM树的节点。这时我们可能会看到红色和黄色两种节点。而我们需要关注的是黄色节点,因为它们是直接被引用的节点,在使用后需要被及时回收,它们的回收会顺带删除下面被间接引用的红色节点。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
另外,我们也可以通过JavaScript内存分配的时间线,从时间的维度来看对象的内存分配,排查内存泄漏点。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
安全测试
|
||||||
|
|
||||||
|
说完性能测试,我们再来看看安全测试。在网络的安全测试中,主要包含渗透测试和漏洞扫描。
|
||||||
|
|
||||||
|
渗透测试可以是黑盒或白盒的,也就是扮演入侵者的测试人员通过前端提供的测试环境和相关开发出来的应用来测试。在黑盒的情况下,入侵者默认没有代码访问,通过尝试,试图渗透到系统内部。
|
||||||
|
|
||||||
|
而漏洞扫描通常是白盒测试,比如很多的云服务厂商都会支持代码托管,在这个基础上,一般会附加漏洞扫描的测试工具。在这个扫描的过程中,负责自动测试的程序可以访问代码库中的代码,并对代码进行扫描,从中发现漏洞。
|
||||||
|
|
||||||
|
无论是渗透测试,还是漏洞扫描,这两种测试都是在软件工程中由专门的测试人员或云平台提供的工具来执行的,所以我们就不在开发测试这里展开讲了。
|
||||||
|
|
||||||
|
下面,我们就从开发的角度,看看我们在程序开发中可以做哪些初步的测试。之前,我们也讲过前端开发过程中可能会遇到的安全问题和解决方案,并且在讲到HTTP的时候,我们也了解了由传输层安全(TLS)和安全套接层(SSL)组成的HTTPS。所以,从前端,如何测试安全连接呢?
|
||||||
|
|
||||||
|
这里,我们同样可以通过浏览器提供的开发者工具。以Chrome为例,在安全页签(Security Tab)看到的主源的安全证书和连接的安全性,以及相关的资源的安全性,可以帮助我们对安全问题做初步的排查。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
辅助功能测试
|
||||||
|
|
||||||
|
说完了安全,下面我们再来看看辅助功能测试。
|
||||||
|
|
||||||
|
辅助功能在很多欧美的网站是很重要的一个功能,它允许一些残障人士通过辅助功能浏览我们开发的页面。这里包含了色弱症、色盲症和失明的人等,他们按道理也应该可以无障碍地访问我们所开发的页面。在开发辅助功能时,我们一般要考虑两个问题,一是用户是否可以用键盘或屏幕阅读器浏览页面,二是页面元素是否为屏幕阅读器做了正确标记。
|
||||||
|
|
||||||
|
这里插播一条数据信息,当我们说到视障人士,大家可能会觉得这部分人群的比例很低,但实际上中国视障群体超过1700万,其中超过800万人完全失明。另外一个经常被忽略的群体是色盲或色弱的群体,中国色弱人数有6000多万,色盲人群有2000多万,加在一起,又是惊人的8000万人。也就是说光在国内,上亿人可能都会因为我们的页面没有辅助功能,而没法正常访问相关的内容。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
开发对这部分人群友好的应用,不仅对大公司来讲是企业责任,即便是从功利的角度来看,这部分人群也是购买我们服务或产品的潜在客户。而越是访问量高的网站,要面对的这个群体就越大。如果我们对这部分用户不加以重视,那影响的不仅是声誉,也是我们的业务。所以无论是从社会发展进步的角度,还是市场角度,我们都应该尽可能为这部分用户提供相关的功能和测试。
|
||||||
|
|
||||||
|
色盲或色弱的人群所面临的主要困扰是无法分辨一些对比度较低的颜色。那么我们怎么能对这部分用户选用更友好的界面颜色呢?我们可以参考相关的指导性的技术标准WCAG,这是一部内容无障碍指南。其中一共有3级,A级是残障用户能够访问和使用Web内容的基本要求;AA级表示了内容总体可访问,并消除了访问内容的较大障碍;AAA级为一些残障用户提供了网络辅助功能的改进和增强。
|
||||||
|
|
||||||
|
还是以Chrome开发者工具为例,在 CSS 总览页签(CSS Overview Tab)下面,我们可以在对比度问题上,看到对比度有问题的字体和背景。比如在下面的例子中,我们可以看到当文字的灰色和背景的灰色相差不多的时候,是没有达到AA标准的,而基于蓝色背景的白色字体达到了AA标准,但是相比较AAA还是差一些。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
另外除了CSS总览页签外,在开发者工具中,我们也可以通过在Lighthouse页签中勾选辅助功能来对网站做审计。通过审计,我们可以看到通过的结果、需要额外手工查看的问题以及在本次测试中不适用的测试用例。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过这一讲,我们看到如何在开发中针对非功能性的性能、安全、辅助功能做测试。而通过测试的这三讲,我们也知道了需要关注的是在开发过程中测试的重要性。从功能性测试中,我们了解了测试并不是一项开发后丢给测试人员的工作,而是测试驱动下“以终为始”的起点。从非功能测试的角度,我们可以看出基于JavaScript开发的前端应用,无论从性能、安全还是辅助功能角度,都有很多的容易被忽视的问题,而单独靠手工的测试无法有效地解决这些问题。这时,就需要借助工具来帮助我们提高测试的效率。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
之前我们说过在JavaScript的多线程开发中,会用到ArrayBuffer和SharedArrayBuffer。那么,你知道如何通过浏览器提供的工具或独立的开发者工具来分析、排查和解决多线程开发中的性能问题吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
138
专栏/JavaScript进阶实战课/35静态类型检查:ESLint语法规则和代码风格的检查.md
Normal file
138
专栏/JavaScript进阶实战课/35静态类型检查:ESLint语法规则和代码风格的检查.md
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
35 静态类型检查:ESLint语法规则和代码风格的检查
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
前面几讲中,我们介绍了JS中的测试,其中包括了单元、UI自动化类的功能性测试,还有性能、安全以及辅助工具等非功能性的测试。通常这些测试都是我们产品上线前的软件工程流水线中重要的环节,因为这些测试所发现的问题会直接影响我们的程序能不能正常运行。
|
||||||
|
|
||||||
|
但是除此之外,有些问题是潜在的,虽然不会对我们的程序有直接的影响,但是可能会间接产生系统风险。根据墨菲定律,我们知道,会出错的事总会出错。那么除了测试以外,我们怎么解决代码中这种潜在的风险呢?这时,就需要用到 linter 代码检查工具。
|
||||||
|
|
||||||
|
今天,我们就通过检查代码质量和风格的工具 ESLint,来看看代码质量检查这项工作。
|
||||||
|
|
||||||
|
代码规范工具
|
||||||
|
|
||||||
|
在编程中 “lint” 这个术语指的是虽然可以运行,但从某种程度上不是最优的代码。这些代码可能引起潜在问题,如bug、安全隐患,或者可读性问题。linter是一种检测代码中是否含有问题代码的工具。linting 则是在代码上运行linter工具,然后修复代码、去除问题代码,直到 linter 不再报错的整个过程。
|
||||||
|
|
||||||
|
lint 并不是 JavaScript 的专有名词。它来源于 C 语言,当 C 语言问世不久的时候,有几个常见的编程错误没有被原始编译器捕捉到,因此一个名为 linter 的辅助工具就诞生了,它可以通过扫描源代码文件来查找问题。随着 C 语言的成熟,该语言的定义得到了加强,消除了风险隐患;同时代码的编译器本身一般也会发出警告,所以之后就不再需要这样额外的代码检查工具了。
|
||||||
|
|
||||||
|
前面,我们介绍过,JavaScript 是一个相对灵活轻量的语言,这是它的优点,但同时也是不足。因为它没有像 C 或Java语言一样的严谨,所以特别是对于大型项目来说,这种灵活性也就代表开发者自身成了用好这门语言的第一责任人。而且除了语言定义上缺乏严谨性外,JavaScript本身也没有官方的编译器,所以时至今日,linting 仍然是代码检查中需要单独工具来处理的一项重要工作。
|
||||||
|
|
||||||
|
其实在 ESLint 之前,道格拉斯·克劳福德曾经开发了 JSLint,一个早期的 JavaScript 代码质量检查工具。它的工作原理就是对源代码进行扫描,如果发现潜在问题,就返回问题在源码中的大致位置和描述。这些潜在问题不一定是语法错误,JSLint 也会关注一些样式约定以及代码结构问题。如之前所说,它不像单元测试,通过检查不能证明程序可以正常运行。JSLint 更多是提供了单元测试外,另外发现潜在问题的途径。因为 JSLint 规范比 ECMAScript 语言的官方定义更严格,所以可以被看做是 JavaScript 的子集。
|
||||||
|
|
||||||
|
在后来的发展中,ESLint 逐渐成为了更加受欢迎的一个 linter 工具。
|
||||||
|
|
||||||
|
首先,我们可以简单了解下它的组成。ESLint 最核心的模块是 Linter、CLIEngine 和 RuleTester。
|
||||||
|
|
||||||
|
|
||||||
|
Linter 是核心中的核心,它没有文件I/O,也不与控制台直接交互。它的主要工作就是根据配置的选项来进行代码验证。
|
||||||
|
CLIEngine 的主要作用是找到源代码文件和配置文件,它包含了配置文件、解析器、插件和格式器的加载逻辑。ESLint使用的是Espree的解析器。
|
||||||
|
RuleTester 的作用是对每一条检查规则做单元测试,RuleTester 内部包装的是 Mocha,对,你没看错,ESLint 用 Mocha 作为内部的单元测试工具。RuleTester 效仿 Mocha 的接口,可以与 Mocha 的全局测试方法一起使用。当然除了 Mocha 以外,RuleTester 也可以和其它的单元测试工具结合使用。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
下面我们再来看看ESLint、API和CLI的关系。ESLint 是通过命令行来执行的文件,实际上它只是一个将命令行作为参数传递给CLI的包装器。CLI 接收到参数后,使用 ESLint 执行命令。API 是 require("esint") 的入口,它暴露的对象包括了 Linter、ESLint、RuleTester 和 Source Code 的公共类。
|
||||||
|
|
||||||
|
最后,我们再来看看Source Code 和 Rules。顾名思义,它们分别代表了源代码和代码测试的内置规则。
|
||||||
|
|
||||||
|
代码规范检查
|
||||||
|
|
||||||
|
以上就是关于代码规范工具的一些介绍,下面,我们就可以具体看看 ESLint 的安装和使用步骤了。首先,通过执行以下命令来安装ESLint。
|
||||||
|
|
||||||
|
npm init @eslint/config
|
||||||
|
|
||||||
|
|
||||||
|
在初始化的过程中,ESLint 会问使用的场景、模块化的类型、项目所使用的框架、是否有使用TypeScript、代码运行的环境和 config 文件的格式等等。之后,程序会检查之前有没有 ESLint 的本地安装。如果没有,就会先进行安装。安装前可以选择相关的包管理工具,这里,我使用的是 NPM,除此之外,有些同学也可能选择 YARN。在一系列的选择之后,ESLint就会运行并完成安装。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
之后,我们通过下面的命令,便可执行代码检查。
|
||||||
|
|
||||||
|
npx eslint yourfile.js
|
||||||
|
|
||||||
|
|
||||||
|
代码规范类型
|
||||||
|
|
||||||
|
那么在检查中,通常会有哪些类型的报错呢?这里,我们先了解下代码规范的目的。因为按照测试驱动的设计思想,如果没有相关的测试目的,那么检查和报错也就无从谈起了。总结来说,代码规范通常有这样两个目的:
|
||||||
|
|
||||||
|
|
||||||
|
提高代码的质量;
|
||||||
|
统一代码的风格。
|
||||||
|
|
||||||
|
|
||||||
|
我们可以通过下面的示例代码来看两个例子。第一个例子,使用 constructor 来构建函数,同 eval() 类似,会使得字符串的内容可能被执行。所以这个例子不仅是代码质量问题,甚至会有安全隐患。
|
||||||
|
|
||||||
|
// bad
|
||||||
|
const add = new Function('a', 'b', 'return a + b');
|
||||||
|
|
||||||
|
// still bad
|
||||||
|
const subtract = Function('a', 'b', 'return a - b');
|
||||||
|
|
||||||
|
|
||||||
|
在下面的例子中,第一行的对象字面量的表达非常长。虽然代码本身的质量没问题,但是这么长的句子会影响代码的可读性。而第二种写法则更加可读。所以通常 linter 工具会要求一行代码的长度不要超过80个字符。这样,可以提高代码的可读性。
|
||||||
|
|
||||||
|
这里,你可能会问,难道写在一行就没有优点吗?其实也不是,如果我们把代码写在一行的话,按说在没有换行的情况下占用的空间会更小,可以压缩文件的大小。但是这个问题通常不是在写代码的时候解决的,而是在程序写完后,可以通过压缩器 JS minifier 来处理。在编写代码的环节,我们始终更重视的问题是我们的代码对于自己和同事是否易读。
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
const foo = { "bar": "This is a bar.", "baz": { "qux": "This is a qux" }, "difficult": "to read" };
|
||||||
|
|
||||||
|
// Good
|
||||||
|
const foo = {
|
||||||
|
"bar": "This is a bar.",
|
||||||
|
"baz": { "qux": "This is a qux" },
|
||||||
|
"difficult": "to read"
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
从上面的例子中,我们可以看到 linter 是一种可以让我们的代码变得更好的方式。但是类似这样的问题,在JavaScript 中有很多。同时在有些时候,大家对“好”的定义可能还不一样。那么遇到这么庞大的规则数量,以及大家对代码风格的不同定义,应该怎么处理呢?
|
||||||
|
|
||||||
|
我们先从规则数量说起。这里,ESLint 已经帮助我们整理了一些常用的规则,我们可以将 ESLint 内置的规则作为一个基准,在上面做相关的定制。在内置的规则中,分为问题、建议、布局和格式。问题和建议的目的主要是提高代码质量,布局和格式的目的主要是统一代码编写风格。
|
||||||
|
|
||||||
|
其次我们看看不同的个人或团队都有自己的风格,该怎么处理?遇到这样的问题,开发者可以通过自定义规则来满足不同的需求,而且这些规则也是可以共享的。比如,Airbnb 就总结了一套自己的 JavaScript 代码编写规范,并且将相关的 ESLint 配置开源了出来,可以供其他开发者使用。那如果我们自己一个人写代码,还有没有必要使用 linter 呢?答案是即使在维护者只有自己的情况下,我们也应该让代码形成自己的前后一致的风格。
|
||||||
|
|
||||||
|
通过插件的方式,ESLint 也可以作为插件和 Angular、React 等三方库结合使用。
|
||||||
|
|
||||||
|
延伸:代码规范化工具
|
||||||
|
|
||||||
|
我们在用到 ESLint 时,核心的诉求还是写出更高质量的代码,其次才是代码的美化。一些项目使用 linter 的原因之一是为了实施一致的编码风格,这样当一个开发团队在共享同一个产品或项目代码库的时候,他们就可以使用兼容的代码约定。这包括代码缩进规则,但也可以包括应该用单引号还是双引号,以及 for 关键字和其后的右括号之间是否应该有空格之类的规则。
|
||||||
|
|
||||||
|
除了 linter 这种代码检查类的工具外,还有一种代码规范化的工具,其中一个例子就是 Prettier。它的工作原理也是对代码进行解析和格式化。例如我们编写了下面的函数,从功能层面讲,它是有效的,但格式不符合常规。
|
||||||
|
|
||||||
|
function HelloWorld({greeting = "hello", greeted = '"World"', silent = false, onMouseOver,}) {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
在此代码上运行 Prettier 可以修复缩进,添加缺少的换行,让代码更加可读。
|
||||||
|
|
||||||
|
function HelloWorld({
|
||||||
|
greeting = "hello",
|
||||||
|
greeted = '"World"',
|
||||||
|
silent = false,
|
||||||
|
onMouseOver,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
|
||||||
|
在使用 Prettier 的时候,如果使用 --write 选项调用,会就地格式化指定的文件,而不是复制后格式化。如果你是用 Git 来管理源代码的话,则可以在代码提交的 hook 中使用 --write 选项来调用 Prettier,它可以让我们在代码提交前自动格式化代码。如果将代码编辑器配置为在每次保存文件时自动运行,则会更加方便。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Prettier 是可配置的,你可以选择代码行的长度限制、缩进量,是否应使用分号,字符串是应该通过单引号还是双引号包裹。虽然它只有几个选项,但总的来说,Prettier 的默认选项非常合理,不需要特别多的改动就可以达到想要的效果。同时,Prettier 也可以通过 eslint-config-prettier 和前面我们说到的 ESLint 结合使用。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
今天的一讲,我们看到了 JavaScript 在给我们提供了编写代码的灵活性的同时,也有着很多潜在的问题,而 linter 作为代码检查工具,可以帮助我们将代码出现质量问题的风险降到最低。同时,我们也可以通过类似 Prettier 这样的代码规范化工具,让我们的代码无论是对自己而言,还是对其他开发者来说,都更加的清晰易读。
|
||||||
|
|
||||||
|
最近,有个很流行的说法是“悲观的人总是正确,乐观的人总是成功”。所以有人可能会觉得墨菲定律过于悲观,但是在程序足够复杂、用户量足够多的情况下,真的是会出错的事总会出错。所以我认为要开发一个成功的应用,开发者应该是一个结合了悲观和乐观主义的人,悲观让我们守住程序稳定运行的基础,乐观让我们不断去突破、创新和优化,这两者之间的空间才决定了我们程序的发展。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
前面我们说过 ESLint 用到的解析器是 Espree,它是基于JQuery 的Esprima 改进的。那么你知不知道它为什么没有沿用Esprima,而是自行创建了一个新的解析器呢?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的看法、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
250
专栏/JavaScript进阶实战课/36Flow:通过Flow类看JS的类型检查.md
Normal file
250
专栏/JavaScript进阶实战课/36Flow:通过Flow类看JS的类型检查.md
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
36 Flow:通过Flow类看JS的类型检查
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
前面我们讲了除了功能性和非功能性测试外,代码的质量检查和风格检查也能帮助我们发现和避免程序中潜在的问题,今天我们再来看看另外一种发现和避免潜在问题的方法——代码类型的检查。说到类型检查,TypeScript 可能是更合适的一门语言,但既然我们这个专栏主讲的是JavaScript,所以今天我们就通过 Flow ——这个 JavaScript 的语言扩展,来学习 JavaScript 中的类型检查。
|
||||||
|
|
||||||
|
如果你有 C 或 Java 语言的开发经验,那么对类型注释应该不陌生。我们拿 C 语言最经典的 Hello World 来举个例子,这里的 int 就代表整数,也就是函数的类型。那么,我们知道在JavaScript中,是没有类型注释要求的。而通过 Flow,我们既可以做类型注释,也可以对注释和未注释的代码做检查。
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
printf("Hello World! \n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
它的工作原理很简单,总结起来就3步。
|
||||||
|
|
||||||
|
|
||||||
|
给代码加类型注释;
|
||||||
|
运行Flow工具来分析代码类型和相关的错误报告;
|
||||||
|
当问题修复后,我们可以通过Babel或其它自动化的代码打包流程来去掉代码中的类型注释。
|
||||||
|
|
||||||
|
|
||||||
|
这里,你可能会想,为什么我们在第3步会删除注释呢?这是因为 Flow 的语言扩展本身并没有改变 JavaScript 本身的编译或语法,所以它只是我们代码编写阶段的静态类型检查。
|
||||||
|
|
||||||
|
为什么需要类型
|
||||||
|
|
||||||
|
在讲 Flow 前,我们先来熟悉下类型检查的使用场景和目的是什么?类型注释和检查最大的应用场景是较为大型的复杂项目开发。在这种场景下,严格的类型检查可以避免代码执行时由于类型的出入而引起的潜在问题。
|
||||||
|
|
||||||
|
另外,我们也来看看 Flow 和 TypeScript 有什么共同点和区别。这里,我们可以简单了解下。先说说相同点:
|
||||||
|
|
||||||
|
|
||||||
|
首先,TypeScript 本身也是 JavaScript 的一个扩展,顾名思义就是“类型脚本语言”;
|
||||||
|
其次,TypeScript 加 TSC 编译器的代码注释和检查流程与 Flow 加 Babel 的模式大同小异;
|
||||||
|
对于简单类型的注释,两者也是相似的;
|
||||||
|
最后,它们的应用场景和目的也都是类似的。
|
||||||
|
|
||||||
|
|
||||||
|
再看看 Flow 和 TypeScript 这两者的区别:
|
||||||
|
|
||||||
|
|
||||||
|
首先,在对于相对高阶的类型上,两者在语法上有所不同,但要实现转换并不难;
|
||||||
|
其次,TypeScript 是早于 ES6,在2012年发布的,所以它虽然名叫 TypeScript,但在当时除了强调类型外,也为了弥补当时 ES5 中没有 class、for/of 循环、模块化或 Promises 等不足而增加了很多功能;
|
||||||
|
除此之外,TypeScript 也增加了很多自己独有的枚举类型(enum)和命名空间(namespace)等关键词,所以它可以说是一个独立的语言了。而 Flow 则更加轻量,它建立在JavaScript的基础上,加上了类型检查的功能,而不是自成一派,但改变了 JavaScript 语言本身。
|
||||||
|
|
||||||
|
|
||||||
|
安装和运行
|
||||||
|
|
||||||
|
有了一些 Flow 相关的基础知识以后,下面我们正式进入主题,来看看Flow的安装和使用。
|
||||||
|
|
||||||
|
和我们之前介绍的 JavaScript 之器中的其它工具类似,我们也可以通过 NPM 来安装 Flow。这里,我们同样也使用了 -g,这样的选项可以让我们通过命令行来运行相关的程序。
|
||||||
|
|
||||||
|
npm install -g flow-bin
|
||||||
|
|
||||||
|
|
||||||
|
在使用 Flow 做代码类型检查前,我们需要通过下面的命令在项目所在地文件目录下做初始化。通过初始化,会创建一个以 .flowconfig 为后缀的配置文件。虽然我们一般不需要修改这个文件,但是对于 Flow 而言,可以知道我们项目的位置。
|
||||||
|
|
||||||
|
npm run flow --init
|
||||||
|
|
||||||
|
|
||||||
|
在第一次初始化之后,后续的检查可以直接通过 npm run flow 来执行。
|
||||||
|
|
||||||
|
npm run flow
|
||||||
|
|
||||||
|
|
||||||
|
Flow 会找到项目所在位置的所有 JavaScript 代码,但只会对头部标注了 // @flow 的代码文件做类型检查。这样的好处是对于已有项目,我们可以对文件逐个来处理,按阶段有计划地加上类型注释。
|
||||||
|
|
||||||
|
前面,我们说过即使对于没有注释的代码,Flow 也可以进行检查,比如下面的例子,因为我们没有把 for 循环中的 i 设置为本地变量,就可能造成对全局的污染,所以在没有注释的情况下,也会收到报错。一个简单的解决方案就是在 for 循环中加一段 for (let i = 0) 这样的代码。
|
||||||
|
|
||||||
|
// @flow
|
||||||
|
let i = { x: 0, y: 1 };
|
||||||
|
for(i = 0; i < 10; i++) {
|
||||||
|
console.log(i);
|
||||||
|
}
|
||||||
|
i.x = 1;
|
||||||
|
|
||||||
|
|
||||||
|
同样,下面的例子在没有注释的情况下也会报错。虽然 Flow 开始不知道 msg 参数的类型,但是看到了长度 length 这个属性后,就知道它不会是一个数字。但是后面在函数调用过程中传入的实参却是数字,明显会产生问题,所以就会报错。
|
||||||
|
|
||||||
|
// @flow
|
||||||
|
function msgSize(msg) {
|
||||||
|
return msg.length;
|
||||||
|
}
|
||||||
|
let message = msgSize(10000);
|
||||||
|
|
||||||
|
|
||||||
|
类型注释的使用
|
||||||
|
|
||||||
|
上面,我们讲完了在没有注释的情况下的类型检查。下面,我们再来看看类型注释的使用。当你声明一个 JavaScript 变量的时候,可以通过一个冒号和类型名称来增加一个 Flow 的类型注释。比如下面的例子中,我们声明了数字、字符串和布尔的类型。
|
||||||
|
|
||||||
|
// @flow
|
||||||
|
let num: number = 32;
|
||||||
|
let msg: string = "Hello world";
|
||||||
|
let flag: boolean = false;
|
||||||
|
|
||||||
|
|
||||||
|
同上面未注释的例子一样,即使在没有注释的情况下,Flow 也可以通过变量声明赋值来判断值的类型。唯一的区别是在有注释的情况下,Flow 会对比注释和赋值的类型,如果发现两者间有出入,便会报错。
|
||||||
|
|
||||||
|
函数参数和返回值的注释与变量的注释类似,也是通过冒号和类型名称。在下面的例子中,我们把参数的类型注释为字符串,返回值的类型注释为了数字。当我们运行检查的时候,虽然函数本身可以返回结果,但是会报错。这是因为我们期待的返回值是字符串,而数组长度返回的结果却是数字。
|
||||||
|
|
||||||
|
// 普通函数
|
||||||
|
function msgSize(msg: string): string {
|
||||||
|
return msg.length;
|
||||||
|
}
|
||||||
|
console.log(msgSize([1,2,3]));
|
||||||
|
|
||||||
|
|
||||||
|
不过有一点需要注意的是 JavaScript 和 Flow 中 null 的类型是一致的,但是 JavaScript 中的 undefined 在 Flow 中是 void。而且针对函数没有返回值的情况,我们也可以用 void 来注释。
|
||||||
|
|
||||||
|
如果你想允许 null 或 undefiend 作为合法的变量或参数值,只需要在类型前加一个问号的前缀。比如在下面的例子中,我们使用了 ?string,这个时候,虽然它不会对 null 参数本身的类型报错,但会报错说 msg.length 是不安全的,这是因为 msg 可能是 null 或 undefined 这些没有长度的值。为了解决这个报错,我们可以使用一个判断条件,只有在判断结果是真值的情况下,会再返回 msg.length。
|
||||||
|
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
function msgSize(msg: ?string): number {
|
||||||
|
return msg.length;
|
||||||
|
}
|
||||||
|
console.log(msgSize(null));
|
||||||
|
|
||||||
|
function msgSize(msg: ?string): number {
|
||||||
|
return msg ? msg.length : -1;
|
||||||
|
}
|
||||||
|
console.log(msgSize(null));
|
||||||
|
|
||||||
|
|
||||||
|
复杂数据类型的支持
|
||||||
|
|
||||||
|
到目前为止,我们学习了几个原始数据类型:字符串、数字、布尔、null 和 undefined 的检查,并且也学习了 Flow 在变量声明赋值、函数的参数和返回值中的使用。下面我们来看一些 Flow 对其它更加复杂的数据类型检查的支持。
|
||||||
|
|
||||||
|
类
|
||||||
|
|
||||||
|
首先我们先来看一下类。类的关键词 class 不需要额外的注释,但是我们可以对里面的属性和方法做类型注释。比如在下面的例子中,prop 属性的类型是数字。方法 method 的参数是字符串,返回值我们可以定义为数字。
|
||||||
|
|
||||||
|
// @flow
|
||||||
|
class MyClass {
|
||||||
|
prop: number = 42;
|
||||||
|
method(value: string): number { /* ... */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
对象和类型别名
|
||||||
|
|
||||||
|
Flow 中的对象类型看上去很像是对象字面量,区别是 Flow 的属性值是类型。
|
||||||
|
|
||||||
|
// @flow
|
||||||
|
var obj1: { foo: boolean } = { foo: true };
|
||||||
|
|
||||||
|
|
||||||
|
在对象中,如果一个属性是可选的,我们可以通过下面的问号的方式来代替 void 和 undefined。如果没有注释为可选,那就默认是必须存在的。如果我们想改成可选,同样需要加一个问号。
|
||||||
|
|
||||||
|
var obj: { foo?: boolean } = {};
|
||||||
|
|
||||||
|
|
||||||
|
Flow 对函数中没有标注的额外的属性是不会报错的。如果我们想要 Flow 来严格执行只允许明确声明的属性类型,可以通过增加以下竖线的方式声明相关的对象类型。
|
||||||
|
|
||||||
|
// @flow
|
||||||
|
function method(obj: { foo: string }) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
method({
|
||||||
|
foo: "test", // 通过
|
||||||
|
bar: 42 // 通过
|
||||||
|
});
|
||||||
|
|
||||||
|
{| foo: string, bar: number |}
|
||||||
|
|
||||||
|
|
||||||
|
对于过长的类型对象参数,我们可以通过自定义类型名称的方式将参数类抽象提炼出来。
|
||||||
|
|
||||||
|
// @flow
|
||||||
|
export type MyObject = {
|
||||||
|
x: number,
|
||||||
|
y: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function method(val: MyObject) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
我们可以像导出一个模块一样地导出类型。其它的模块可以用导入的方式来引用类型定义。但是,这里需要注意的是,导入类型是 Flow 语言的一个延伸,不是一个实际的 JavaScript 导入指令。类型的导入导出只是被 Flow 作为类型检查来使用的,在最终执行的代码中,会被删除。最后需要注意的是,和创建一个 type 比起来,更简洁的方式是直接定义一个 MyObject 类,用来作为类型。
|
||||||
|
|
||||||
|
我们知道在 JavaScript 中,对象有时被用来当做字典或字符串到值的映射。属性的名称是后知的,没法声明成一个 Flow 类型。但是,我们还是可以通过 Flow 来描述数据结构。假设你有一个对象,它的属性是城市的名称,值是城市的位置。我们可以将数据类型通过下面的方式声明。
|
||||||
|
|
||||||
|
// @flow
|
||||||
|
var cityLocations : {[string]: {long:number, lat:number}} = {
|
||||||
|
"上海": { long: 31.22222, lat: 121.45806 }
|
||||||
|
};
|
||||||
|
export default cityLocations;
|
||||||
|
|
||||||
|
|
||||||
|
数组
|
||||||
|
|
||||||
|
数组中的同类元素类型可以在角括号中说明。一个有着固定长度和不同类型元素的数组,叫做元组(tuple)。在元组中,元素的类型在用逗号相隔的方括号中声明。
|
||||||
|
|
||||||
|
我们可以通过解构(destructuring)赋值的方式,加上 Flow 的类型别名功能来使用元组。
|
||||||
|
|
||||||
|
如果我们希望函数能够接受一个任意长度的数组作为参数时就不能使用元组了,这时我们需要用的是 Array<mixed>。mixed 表示数组的元素可以是任意类型。
|
||||||
|
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
function average(data: Array<number>) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
let tuple: [number, boolean, string] = [1, true, "three"];
|
||||||
|
let num : number = tuple[0]; // 通过
|
||||||
|
let bool : boolean = tuple[1]; // 通过
|
||||||
|
let str : string = tuple[2]; // 通过
|
||||||
|
|
||||||
|
function size(s: Array<mixed>): number {
|
||||||
|
return s.length;
|
||||||
|
}
|
||||||
|
console.log(size([1,true,"three"]));
|
||||||
|
|
||||||
|
|
||||||
|
如果我们的函数对数组中的元素进行检索和使用,Flow 检查会使用类型检查或其他测试来确定元素的类型。如果你愿意放弃类型检查,你也可以使用 any 而不是 mixed,它允许你对数组的值做任何处理,而不需要确保这些值是期望的类型。
|
||||||
|
|
||||||
|
函数
|
||||||
|
|
||||||
|
我们已经了解了如何通过添加类型注释来指定函数的参数类型和返回值的类型。但是,在高阶函数中,当函数的参数之一本身是函数时,我们也需要能够指定该函数参数的类型。要用 Flow 表示函数的类型,需要写出用逗号分隔、再用括号括起来的每个参数的类型,然后用箭头表示,最后键入函数的返回类型。
|
||||||
|
|
||||||
|
下面是一个期望传递回调函数的示例函数。这里注意下,我们是如何为回调函数的类型定义类型别名的。
|
||||||
|
|
||||||
|
// @flow
|
||||||
|
type FetchTextCallback = (?Error, ?number, ?string) => void;
|
||||||
|
function fetchText(url: string, callback: FetchTextCallback) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
虽然使用类型检查需要很多额外的工作量,当你开始使用 Flow 的时候,也可能会扫出来大量的问题,但这是很正常的。一旦你掌握了类型规范,就会发现它可以避免很多的潜在问题,比如函数中的输入和输出值类型与期待的参数或结果不一致。
|
||||||
|
|
||||||
|
另外,除了我们介绍的数字、字符串、函数、对象和数组等这几种核心的数据类型,类型检查还有很多用法,你也可以通过Flow 的官网了解更多。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
在类型检查中,有两种思想,一种是可靠性(soundness),一种是完整性(completeness)。可靠性是检查任何可能会在运行时发生的问题,完整性是检查一定会在运行时发生的问题。第一种思想是“宁可误杀,也不放过”;而后者的问题是有时可能会让问题“逃脱”。那么你知道Flow或TypeScript遵循的是哪种思想吗?你觉得哪种思想更适合实现?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的看法、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
100
专栏/JavaScript进阶实战课/37包管理和分发:通过NPM做包的管理和分发.md
Normal file
100
专栏/JavaScript进阶实战课/37包管理和分发:通过NPM做包的管理和分发.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
37 包管理和分发:通过NPM做包的管理和分发
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在前面几讲中,我们看到无论是响应式编程框架React,还是测试用的Jest、Puppeteer工具,亦或是做代码检查和样式优化的ESLint、Prettier工具,都离不开第三方库,而我们在之前的例子中,都是通过NPM下载和安装工具的。
|
||||||
|
|
||||||
|
所以,今天我们就来深入了解下NPM以及包的管理和发布。
|
||||||
|
|
||||||
|
包的发布
|
||||||
|
|
||||||
|
NPM(Node Package Manager)虽然它叫做 Node 包管理,但是其实你也可以用它管理或发布用 JavaScript 以外的语言编写的程序的包,只不过NPM最主要的受众还是 JavaScript 在Web和服务器端的开发者。在 NPM 中,有两个核心的概念,一个是包(package),另外一个是模块(module)。
|
||||||
|
|
||||||
|
包是一个含有 package.json 文件的文件夹。package.json 的作用是对包中的文件或目录的描述。一个包必须含有一个 package.json 文件才能发布到 NPM 的注册列表。
|
||||||
|
|
||||||
|
模块是任何可以被 Node.js 的 require() 加载的文件或目录。能够被成功加载的模块必须是一个带有 main 字段的 pakcage.json 的目录,或者一个JavaScript的文件。
|
||||||
|
|
||||||
|
一个包的发布很简单,首先在命令行通过创建 mkdir 和改变目录 cd 的命令,我们可以创建一个包的文件夹,并导航到包的根目录。在目录下,我们可以创建一个 package.json 的文件。package.js 文件的创建方式有两种,一种是直接创建,另外一种是在命令行上执行 npm init 的命令,通过提示输入后,生成 package.json 的文件。
|
||||||
|
|
||||||
|
在一个 package.json 中,必须要包含的字段是名称和版本号。有些时候,一个包是带有对其它包的依赖的,在这种情况下,在package.json中,也可以设置相关的依赖关系。
|
||||||
|
|
||||||
|
如果模块是在Git上管理的,可以在包所在的根目录下,将模块加进来。或者,我们也可以选择直接在目录下写一个模块。以下面的 printMsg 模块为例,这里通过 export 导出的模块,可以在其它的程序中通过 require() 导入引用。
|
||||||
|
|
||||||
|
exports.printMsg = function() {
|
||||||
|
console.log("This is a message from the demo package");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
在我们创建了一个包之后,在正式发布前,最好先通过 npm install 自己测试一下。确保无误之后,我们可以通过 npm publish 对包进行发布。无论是公开还是私有包的发布,都需要在发布前在NPM的注册页面上创建一个用户。为了安全,发布包以前,最好通过2FA的双因子认证。
|
||||||
|
|
||||||
|
以上是对公开包的发布流程。除了公开的包以外,我们也可以发布私有的包。那么公开和私有的包有什么区别呢?对于公开的包来说,所有人都有读取和下载的权限,针对指定的用户或组织,可以设置写和发布的权限。对于私有的包来说,只有指定的人员或组织内的成员才可以读取、下载、写入或发布相关的包。
|
||||||
|
|
||||||
|
包的持续集成和部署
|
||||||
|
|
||||||
|
在DevOps,我们经常强调 CI/CD 的持续集成和部署。在使用 NPM 的时候,我们也可以通过访问令牌的方式来代替用户名和密码的认证方式。这里的访问令牌是一个十六进制字符串,我们可以使用它来进行身份验证、安装或发布模块。通过这种方式,我们可以让其它工具,例如持续集成测试环境访问 NPM 的包。当我们的工作流在运行时,它可以完成包括安装私有包的任务。
|
||||||
|
|
||||||
|
而令牌又分为两类,一种是传统令牌,另外一种是粒度令牌。其中,传统令牌主要分为3类,一种是只读的,它只可以下载包;第二种是在下载的基础上可以安装;第三种是在前两种的基础上可以发布包。但是从安全的角度考虑,粒度令牌,顾名思义,有更细粒度的权限管理。粒度令牌还可以更好地区分可以访问的包和范围,授权给指定的组织,设置过期时间,基于CIDR的方法来控制授权的IP范围,并且提供只读和读写的权限选项。
|
||||||
|
|
||||||
|
举个例子,我们可以通过下面的方式,创建一个基于IP范围的访问令牌。
|
||||||
|
|
||||||
|
npm token create --cidr=192.0.2.0/24
|
||||||
|
|
||||||
|
|
||||||
|
之后,我们可以将令牌设置为CI/CD服务器中的环境变量或密钥。例如,在GitHub Actions中,我们可以将令牌添加为密钥。然后根据该密钥,创建一个名为 NPM_TOKEN 的环境变量,将密钥提供给工作流。
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
npm install
|
||||||
|
- env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
|
||||||
|
这里值得强调的是,这里的令牌具有可以读取私有包,代表我们发布新包,更改用户或包设置的权限。所以从安全的角度考虑,我们必须保护好令牌。千万不能将令牌添加到版本控制或存在不安全的地方。最好是将令牌存储在密码管理器、云提供商的安全存储或CI/CD工具提供的安全存储器中。如果可能,应该使用我们前面讲到的具有最低权限的粒度访问令牌,并为令牌设置较短的过期时间。
|
||||||
|
|
||||||
|
包的管理
|
||||||
|
|
||||||
|
前面,我们说过,对于包的权限,我们可以选择公开或者私有化。无论是哪种模式下,我们都可以对用户和组织进行管理。区别只是在于,公开的包所有人都可以读取和下载;而私有的包,无论是读取下载还是写入发布,都需要指定的用户或组织,才能赋予权限。下面,我们就来看看,如何在 NPM 中对组织进行管理。
|
||||||
|
|
||||||
|
首先,我们在登录后,点击头像可以选择创建一个组织,之后,我们需要给组织起一个名字。这个时候,我们可以选择免费和付费的方式,这两者的区别在于免费的版本可以让我们创建公开的包,而私有的包则需要付费。在这个过程中,你也可以选择将一个个人账户转换成一个组织账户。
|
||||||
|
|
||||||
|
这时,我们也可以通过姓名和邮箱邀请加入组织的用户。我们可以选择用户的角色和加到的团队,比如角色可以是“成员”或者是“管理员”。在这里,只有所有者有权利添加或删除组织中的成员,改变成员的角色,或者对组织的名称做修改或者删除。“管理员”可以管理团队,这里包括了团队的创建和删除,同时可以管理团队成员的加入和移除,以及包的访问权限。“成员”的权限是可以创建、发布组织范围内的包。
|
||||||
|
|
||||||
|
在组织创建的时候,一个“开发”组是自动生成的。除此之外,组织的所有者和管理员也可以创建更多的组。比如对于有些用户,我们不希望他们拥有开发权限,可以将他们从“开发”组移到项目管理组。
|
||||||
|
|
||||||
|
安全考虑
|
||||||
|
|
||||||
|
前面,我们学习了 NPM 的发布、CI/CD 和管理的流程。下面,我们再来看看 NPM 的安全考虑。在使用 NPM 的时候,很重要的一点就是安全考量。这里,我们可以从开发者和使用者两个不同的角度来看。
|
||||||
|
|
||||||
|
首先,我们先从开发者的角度来看,这里最需要警惕的就是我们的账户安全。
|
||||||
|
|
||||||
|
针对子账户安全,最容易受到的就是密码攻击。当然密码攻击是一种常见的网络攻击,不仅限于 NPM,而是在任何Web服务上都有可能遭受到的攻击。保护帐户安全的最佳方法就是我们前面提到的启用双因子身份验证(2FA)。
|
||||||
|
|
||||||
|
在此基础上,安全性最强的选项是使用安全密钥,无论是内置于设备还是外部硬件的密钥。安全密钥可以将身份验证绑定到正在访问的站点,大大降低网络钓鱼的风险。但因为并不是所有人都可以访问到安全密钥,所以NPM还支持为2FA生成一次性密码的身份验证应用程序。
|
||||||
|
|
||||||
|
由于这种攻击的常见性,以及 NPM 包的流行程度和对开源生态系统的影响,NPM 采取了分期的方法,对排名前100的软件包维护者和排名前500的软件包管理者强制实行2FA的认证。当然,这样的举措还是远远不够的,在不久的将来,所有高影响力软件包的维护者(每周下载量超过100万次或依赖500次以上的软件包)都将被强制执行2FA验证。如果你作为包的发布者不选择2FA的验证,NPM 会通过向你的电子邮箱发送一次性密码来加强登录验证,以防止帐户被盗取。
|
||||||
|
|
||||||
|
盗取帐户的另一种方法是,通过使用过期域名作为电子邮件地址来识别帐户。攻击者可以注册过期的域名并重新创建用于注册帐户的电子邮件地址。通过访问帐户的注册电子邮件地址,攻击者可以通过重置密码来盗取不受2FA保护的帐户。
|
||||||
|
|
||||||
|
当一个包在发布的时候,发布包与帐户关联的电子邮件地址是包含在公共元数据中的。攻击者能够利用这些公共数据来识别可能容易被账户盗取的账户。NPM 也会定期检查帐户电子邮件地址是否有过期的域名或无效的MX记录。域名在过期之后,NPM 会禁用帐户进行密码重置,并要求用户在重置密码之前进行帐户恢复或成功通过身份验证的流程。需要注意的是,作为包的维护者,在你更新电子邮箱地址的时候,存储在包的公共元数据中的电子邮箱地址是不会更新的。由于这种抓取公共元数据以识别易受过期域名影响的帐户将导致误报,因此这些帐户看似易受攻击,但实际上并非如此。
|
||||||
|
|
||||||
|
下面,我们再从使用者的角度来看看。攻击者可能会试图通过注册与流行软件包名称相似的软件包来诱骗他人安装恶意软件包,希望人们因为笔误输入错误的名称,或者用其它的方式混淆两者。NPM 能够检测错别字攻击并阻止这些包的发布。这种攻击的一种衍生攻击是当公共包与组织正在使用的私有包以相同的名称注册时,私有包被公共注册表中的包取代。所以这里的一个建议是,使用范围包(scoped package),以确保私有包不会被公共注册表中的包取代。
|
||||||
|
|
||||||
|
另外一个问题是对现有包的恶意更改行为,在这里,攻击者也有可能不会诱骗用户使用类似名称的软件包,而是试图将恶意行为添加到现有的流行软件包中。为了解决这个问题,NPM 也在与微软合作,扫描软件包中已知的恶意内容,并运行软件包来寻找潜在的新的恶意行为模式。这使得 NPM 包中带有恶意内容的比例大幅减少。此外,NPM 的信任和安全团队会检查并删除用户报告的恶意行为和内容。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过这一讲,我们看到了在JavaScript不断模块化的今天,NPM 让我们更容易分享和使用其他开发者所提供的工具,而且除了模块自身的功能外,NPM 也可以是很好的版本和依赖管理工具,并且 NPM 的使用也可以促进团队的协作。但是同时,我们也看到了它的很多安全隐患。所以在使用的时候,还是要谨慎。作为开发者,我们不希望自己好心提供的工具成为了黑客进行攻击的手段,同时,作为用户,我们更应该注意管理从 NPM 下载的第三方代码的风险。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
前面,我们讲了在使用 NPM 的时候,最重要的就是安全的考量,当然除了我们上面说到的这些方法外,之前在安全的一讲中,我们提到的漏扫也都是降低风险的方式。那么在实际开发中,你会使用 NPM 吗?除了我们讲到的方法外,你还能想到其他的安全措施吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
124
专栏/JavaScript进阶实战课/38编译和打包:通过Webpack、Babel做编译和打包.md
Normal file
124
专栏/JavaScript进阶实战课/38编译和打包:通过Webpack、Babel做编译和打包.md
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
38 编译和打包:通过Webpack、Babel做编译和打包
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在 JavaScript 从 ES5 升级到 ES6 的时候,在大多浏览器还尚未支持新版的JavaScript的时候,很多开发者就想要预先体验相关的功能,但是却愁于没有相关的环境支持。当时,为了解决这个问题,一些面向 JavaScript 开发者的编译器如 Babel.js 就诞生了,它允许开发者按照 ES6 版的 JavaScript 语法来编写程序,然后再将代码编译转化成与 ES5 兼容的版本。
|
||||||
|
|
||||||
|
当然后来,随着浏览器对新版 JavaScript 的支持,Babel.js 不单单是解决了 ES6 的使用问题,同时它也加入了很多新的功能,支持更多的编译需求。今天,我们就来看下 JavaScript 开发中用到的 Babel 编译。
|
||||||
|
|
||||||
|
JavaScript的编译
|
||||||
|
|
||||||
|
我们知道 JavaScript 是以 ES6 版本作为一个重要里程碑的。它和在此之前的 ES5 版本相隔了6年,而在 ES6 问世之后,JavaScript 就保持了每年的更新。所以 ES6 可以被看做是一个“大版本”的更新,里面包含了很多的新功能和语法糖。
|
||||||
|
|
||||||
|
面对不同浏览器对 ES6 的支持程度的不一致,有两种处理的办法。一种是编译,一种是 polyfill。这两者没有明显的界限,但是大致的区别是编译会在运行前先将代码转化成低版本的代码,再运行。而 polyfill 则是在运行时判断浏览器是否支持一个功能,只有在不支持的情况下,才使用补丁代码;如果支持,就使用原生的功能。
|
||||||
|
|
||||||
|
今天,我们重点说到的就是编译这种方式。Babel 在过去很长一段时间,提供了用来帮助开发者提前使用下一代的 JavaScript 版本来编写代码的编译工具,它可以将 ES6+ 的代码编译成向下兼容的版本。你可能会问,编译不是浏览器的工作吗?为什么我们说Babel是一个编译器呢?
|
||||||
|
|
||||||
|
其实Babel是一种从源代码到源代码的编译,不是从源代码到机器码的编译,所以为了和浏览器中的 JavaScript 引擎做区分,Babel 也被叫做“转”译器(transcompiler 或 transpiler)。Babel 在2014年推出后,在很多浏览器尚未提供 ES6 支持的情况下,就让Web开发人员能够使用 ES6 和更高版本的新语言功能了。
|
||||||
|
|
||||||
|
有些 ES6 的语言特性可以相对容易地转换为 ES5,例如函数表达式。但是有些语言特性,例如class 关键字等则需要更复杂的转换。通常,Babel 输出的代码不一定是开发者可读的,但是可以将生成的代码与源代码的位置进行映射,这在编译后,运行时对问题的排查有很大的帮助。
|
||||||
|
|
||||||
|
随着主流浏览器对 ES6+ 的版本支持越来越快,以及 IE 退出了历史舞台,如今编译箭头函数和类声明的需求也大大减少了。但对一些更新的语言特性,Babel 仍然可以提供帮助。与我们前面描述的大多数其它工具一样,你可以使用 NPM 安装 Babel 并使用 NPX 运行它。
|
||||||
|
|
||||||
|
Babel 可以通过 npm install --save-dev @babel/core 来安装,然后直接在代码中导入使用,但是这样的使用会把编译的工作加到终端用户侧,更合理的方式应该是将 Babel 的使用放在开发流程中。
|
||||||
|
|
||||||
|
const babel = require("@babel/core");
|
||||||
|
babel.transformSync("code", optionsObject);
|
||||||
|
|
||||||
|
|
||||||
|
在开发的流程中加入 Babel 的方式如下:
|
||||||
|
|
||||||
|
npm install --save-dev @babel/core @babel/cli
|
||||||
|
./node_modules/.bin/babel src --out-dir lib
|
||||||
|
|
||||||
|
|
||||||
|
之后,我们可以加入预设(preset)。预设的加入有两种方式,一种是将所用的编译插件都一次性安装;另外一种则是只针对特定的插件,比如箭头函数做安装。
|
||||||
|
|
||||||
|
npm install --save-dev @babel/preset-env
|
||||||
|
./node_modules/.bin/babel src --out-dir lib --presets=@babel/env
|
||||||
|
|
||||||
|
npm install --save-dev @babel/plugin-transform-arrow-functions
|
||||||
|
./node_modules/.bin/babel src --out-dir lib --plugins=@babel/plugin-transform-arrow-functions
|
||||||
|
|
||||||
|
|
||||||
|
假如我们安装了上述的箭头函数插件,那么在代码中,如果我们使用相关的箭头,Babel 在编译的过程中就会将代码转化成 ES5 版本的样式。在 Babel 内部,通过读取一个 .babelrc 配置文件,来判断如何转换 JavaScript 代码。所以,你可以按需创建想要编译的功能,来进行这些预设,也可以全量使用所有插件,来对所有的功能进行相关的转译。比如在下面这个例子中,就是将一个箭头函数转化为和ES5版本兼容的代码。
|
||||||
|
|
||||||
|
// Babel输入: ES6箭头函数
|
||||||
|
[1, 2, 3].map(n => n + 1);
|
||||||
|
|
||||||
|
// Babel输出: ES5匿名函数
|
||||||
|
[1, 2, 3].map(function(n) {
|
||||||
|
return n + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
尽管现在很多时候,我们已经不太需要转换 JavaScript 的核心语言了,但 Babel 仍然常用于支持 JavaScript 语言的非标准扩展,其中一个就是我们在前面一讲提到的 Flow。Babel 在编译的过程中,可以帮助我们去掉 Flow 的类型注释。除了 Flow 外,Babel 也支持去掉 TypeScript 语言的类型注释。
|
||||||
|
|
||||||
|
npm install --save-dev @babel/preset-flow
|
||||||
|
npm install --save-dev @babel/preset-typescript
|
||||||
|
|
||||||
|
|
||||||
|
通过把 Babel 和一个代码打包工具结合起来使用,我们可以在 JavaScript 文件上自动运行 Babel。这样做可以简化生成可执行代码的过程。例如,Webpack 支持一个 “babel 加载器”模块,你可以安装并配置该模块,以便在打包的每个 JavaScript 模块上运行 babel。那么说到这里,下面我们再来看看 JavaScript 中的打包工具。
|
||||||
|
|
||||||
|
JavaScript的打包
|
||||||
|
|
||||||
|
如果你使用 JavaScript 做模块化开发的话,应该对代码打包不会陌生。即使是在ES6之前,相关的导入(import)和导出(export)指令还没有被正式引入之前,人们就开始使用这些功能了。
|
||||||
|
|
||||||
|
当时,为了能提前使用这些功能,开发者会使用一个代码打包工具以一个主入口为开始,顺藤摸瓜,通过导入指令树查找程序所依赖的所有模块。然后,打包工具会把所有单独模块的文件合并成一个JavaScript 代码文件,然后重写导入导出指令,让代码可以以转译后的形式运行。打包后的结果,是一个可以被加载到不支持模块化的浏览器的单一文件。
|
||||||
|
|
||||||
|
时至今日,ES6的模块几乎被所有的主流浏览器支持了,但是开发者却仍然使用代码打包工具,至少在生产发布时还是这样的。这么做的原因是为了将核心功能一次性加载,这样比起一个个模块单独加载,性能更高,并且可以带来更好的用户体验。
|
||||||
|
|
||||||
|
目前市面上有很多不错的 JavaScript 打包工具。其中比较出名的有Webpack、Rollup 和 Parcel。这些打包工具的基础功能基本上都大同小异,它们的区别主要是在配置和易用性上。Webpack可以算是这几个工具中最元老级的一个了,并且可以支持比较老的非模块化的库。但同时,它也比较难配置。和它正好相反的是Parcel,一个零配置的替代方案。而 Rollup 相比 Webpack,更加简约,适合小型项目的开发。
|
||||||
|
|
||||||
|
除了基础的打包外,打包工具也可以提供一些额外的功能。比如加载的优化,非标模块化插件,更新加载和源代码问题排查等等。下面,让我们一一来看下这些功能。
|
||||||
|
|
||||||
|
加载优化
|
||||||
|
|
||||||
|
比如很多程序都有多个入口。一个有很多页面的Web应用,每个页面都有不同的入口。打包工具通常会允许我们基于每个入口或几个入口来创建一个包。
|
||||||
|
|
||||||
|
前面我们在讲到前端的设计模式的时候,曾经说过,一个程序除了可以在初始化时静态加载资源外,也可以使用导入来按需动态加载模块,这样做的好处是可以优化应用初始化的时间。通常支持导入的打包工具可以创建多个导出的包:一个在初始时加载的包,和一个或多个动态加载的包。多个包适用于程序中只有几个 import 调用,并且它们加载的模块没有什么交集。如果动态加载的模块对依赖高度共享的话,那么计算出要生成多少个包就会很难,并且很可能需要手动配置包来进行排序。
|
||||||
|
|
||||||
|
有时,当我们在模块中引入一个模块的时候,我们可能只用其中的几个功能。一个好的打包工具可以通过分析代码,来判断有哪些未使用的代码是可以从打包中被删除的。这样的功能就是我们前面讲过的摇树优化(tree-shaking)。
|
||||||
|
|
||||||
|
非标模块化插件
|
||||||
|
|
||||||
|
打包工具通常有一个支持插件的架构,并且支持导入和打包非JavaScript代码的模块。假设你的程序包含一个很大的JSON兼容的数据结构,我们可以用代码打包工具来将它配置成一个单独的JSON文件,然后通过声明的方式将它导入到程序中。
|
||||||
|
|
||||||
|
import widgets from "./app-widget-list.json"
|
||||||
|
|
||||||
|
|
||||||
|
类似的,我们也可以使用打包工具的插件功能,在 JavaScript 中,通过 import 来导入 CSS 文件。不过,这里需要注意的是,导入任何 JS 以外的文件所使用的都是非标准的扩展,并且会让我们的代码对打包工具产生一定程度上的依赖。
|
||||||
|
|
||||||
|
更新加载
|
||||||
|
|
||||||
|
在像 JavaScript 这种在执行前不需要预先编译打包的语言里,运行一个打包工具的感觉像是一个预先编译的过程,每次写完代码,都需要打包一次才能在浏览器中执行,这个步骤对于有些开发者而言,可能感觉比较繁琐。
|
||||||
|
|
||||||
|
为了解决这个问题,打包工具通常支持文件系统以观察者的模式来侦测项目目录中文件的改动,并且基于改动来自动重新生成所需的包。通过这个功能,你通常可以在保存编辑过的文件后,在不需要手动再次打包的情况下,及时地刷新。有些打包工具还会支持针对开发者的“热更新”选项。每次重新打包的时候,会自动加载到浏览器。
|
||||||
|
|
||||||
|
源代码排查
|
||||||
|
|
||||||
|
和 Babel 等编译工具类似,打包工具通常也会生成一个源代码和打包后代码的映射文件。这样做的目的,同样是帮助浏览器开发者工具在报错的时候,可以自动找到问题在源文件中的所在位置。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过今天的学习,我们了解了 JavaScript 中编译和打包工具的前世今生和“成功转型”。
|
||||||
|
|
||||||
|
Babel 作为编译器,在很长一段时间能让人们提前使用到 ES6 的功能,在“转型”后,又是在不改变原始的 JavaScript 语言的基础上,让人们可以使用 Flow 和 TypeScript 做类型标注,为其进行编译。
|
||||||
|
|
||||||
|
之后,我们学习了Webpack、Rollup等代码打包工具,在早期起到了模块化导入和导出的作用,在后期逐渐“转型”为加载优化,提供非标模块化插件的支持,并且提供了代码实时更新打包和热加载的功能。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
今天的思考题,也是作为下面两节课的预习。转型后的 Babel 除了能做到对 Flow 和 TypeScript 进行转译支持,也能支持 JSX 的转译,你知道 JavaScript 中的 JSX 语法扩展的作用是什么吗?
|
||||||
|
|
||||||
|
另外一个问题,是我们今天提到除了 Babel 转译外,通过 polyfill 我们也可以解决功能兼容的问题,那么你知道它俩使用场景上的区别吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
241
专栏/JavaScript进阶实战课/39语法扩展:通过JSX来做语法扩展.md
Normal file
241
专栏/JavaScript进阶实战课/39语法扩展:通过JSX来做语法扩展.md
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
39 语法扩展:通过JSX来做语法扩展
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在前面一讲中,我们提到了 React 也有一个 JavaScript 的语法扩展,叫做 JSX。它的全称是 JavaScript XML,顾名思义,它是以 XML 为格式设计的。它最主要的目的是与 React 框架结合,用于应用中 Web UI 相关的开发。在React中,DOM 树是通过 JSX 来定义的,最终会在浏览器中渲染成 HTML。基于 React 的普及率,即使你没有用过 React,估计对 JSX 也有所耳闻。
|
||||||
|
|
||||||
|
今天,我们就来看看 JSX 是如何用在 Web UI 开发中的。即使你不使用 React,这样的模版模式也有很大的借鉴意义。
|
||||||
|
|
||||||
|
类HTML的元素
|
||||||
|
|
||||||
|
首先,我们来看下 JSX 的基本使用,它是如何创建元素、相关的属性和子元素的。
|
||||||
|
|
||||||
|
你可以把 JSX 元素当成一种新的 JavaScript 语法表达。我们知道在标准的 JavaScript 中,字符串的字面量是通过双引号来分隔的,正则表达式的字面量是通过斜线号来分隔的。类似的,JSX 的字面量则是通过角括号来分隔的。比如下面的 标签就是一个简单的例子,通过这种方式,我们就创建了一个 React 的标题元素。
|
||||||
|
|
||||||
|
var title = <h1 className="title">页面标题</h1>;
|
||||||
|
|
||||||
|
|
||||||
|
但是像上面这样的标签,我们知道JS引擎是没办法理解的,那它如何被运行呢?这时,我们可以通过我们在上一讲提到的 Babel 把 JSX 的表达先转译成标准的 JavaScript,然后才会给到浏览器做编译和执行。Babel 会把上面变量声明赋值中的 JSX 表达,转换成一个符合 JavaScript 规范的 createElement() 函数,来进行函数的调用。
|
||||||
|
|
||||||
|
var title = React.createElement("h1", {className: 'title'}, "页面标题");
|
||||||
|
|
||||||
|
|
||||||
|
因为通过 JSX 来创建的 React 元素和 HTML 的标签元素类似,所以 JSX 生成的 React 元素表达也可以带有属性。当一个元素有一个或多个属性的时候,会作为 createElement() 的第二个参数传入,参数的值是一个带有元素相关属性的对象。
|
||||||
|
|
||||||
|
// 转译前
|
||||||
|
var image = <img src="logo.png" alt="company logo" />;
|
||||||
|
|
||||||
|
// 转译后
|
||||||
|
var image = React.createElement("img", {
|
||||||
|
src: "logo.png",
|
||||||
|
alt: "company logo"
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
像 HTML 的元素一样,除了字符串,React 的元素中也可以有子元素。React 元素也可以通过层级的嵌套,来创建 DOM 树。在转译的过程中,上面的这些 JSX 嵌套的 DOM 子元素,将作为 createElement() 调用的第三个参数及以后的参数来传递。
|
||||||
|
|
||||||
|
// 转译前
|
||||||
|
var sidebar = (
|
||||||
|
<div className="sidebar">
|
||||||
|
<h2 className="menu">菜单</h2>
|
||||||
|
<p className="text">菜单内容</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 转译后
|
||||||
|
var sidebar = React.createElement(
|
||||||
|
"div", { className: "sidebar"},
|
||||||
|
React.createElement("h1", className: "menu"},
|
||||||
|
"菜单"),
|
||||||
|
React.createElement("p", className: "text"},
|
||||||
|
"菜单内容"));
|
||||||
|
|
||||||
|
|
||||||
|
React 中的 createElement() 函数所返回的值是一个 JavaScript 对象,React 使用它在浏览器窗口中输出渲染的UI。在下面的例子中,我们可以看到一个通过 JSX 表达的 React 元素,在转译后,通过 createElement() 生成为对象。之后,再将对象通过 React 中的 render() 渲染到页面上。
|
||||||
|
|
||||||
|
// 转译前
|
||||||
|
var element = <h1 class"title">页面标题</h1>;
|
||||||
|
|
||||||
|
// 转译后
|
||||||
|
var element = React.createElement(
|
||||||
|
'h1',
|
||||||
|
{className: 'title'},
|
||||||
|
'页面标题'
|
||||||
|
);
|
||||||
|
|
||||||
|
// createElement 返回的对象
|
||||||
|
var element = {
|
||||||
|
type: 'h1',
|
||||||
|
props: {
|
||||||
|
className: 'title',
|
||||||
|
children: '页面标题'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// createElement 返回对象的渲染
|
||||||
|
var root = ReactDOM.createRoot(
|
||||||
|
document.getElementById('root')
|
||||||
|
);
|
||||||
|
root.render(element);
|
||||||
|
|
||||||
|
|
||||||
|
因为今天我们主要讲的是 JSX 的语法扩展,而不是 React 本身,所以我们就不对返回的 element 对象和渲染过程的细节做赘述了。不过,有一点值得注意的是,你可以用 Babel 配置对 React 中 createElement() 以外的创建元素的函数做转译。这样做,就可以在 React 以外的地方来使用类似的 JSX 表达了。
|
||||||
|
|
||||||
|
JS和CSS的表达
|
||||||
|
|
||||||
|
上面,我们学习了 JSX 语法扩展的核心概念,下面,我们再来看看它是如何与 JavaScript 以及 CSS 的表达来结合使用的。
|
||||||
|
|
||||||
|
React 元素的一个重要特性是可以在 JSX 表达式中嵌入标准的 JavaScript 表达式。你可以使用大括号来嵌入标准的 JavaScript 表达式。大括号内的脚本,会在转译的环节被解释为标准的 JavaScript。React 元素中的嵌套表达式可以作为属性值和子元素。下面就是一个例子:
|
||||||
|
|
||||||
|
function article(className, title, content, linebreak=true) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{ linebreak && <br /> }
|
||||||
|
<p>{content}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function article(className, title, content, subtitle=true) {
|
||||||
|
return React.createElement("div", { className: className },
|
||||||
|
React.createElement("h1", null, title),
|
||||||
|
subtitle && React.createElement("br", null),
|
||||||
|
React.createElement("p", null, content));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
在这个例子中,article() 函数的作用是返回一个 JSX 元素,它有四个参数。Babel 会将例子中的代码翻译为以下内容。
|
||||||
|
|
||||||
|
这段代码很容易阅读和理解:转译后,大括号消失了,生成的代码将 article() 函数传入的参数传递给 React.createElement()。请注意我们在这里对 linebreak 参数和 && 运算符的使用。在实际的调用中,如果只有三个实参传入 article(),则 默认为 true,外部 createElement() 调用的第四个参数是 元素。但是,如果我们将 false 作为第四个参数传递给 article(),那么外部 createElement() 调用的第四个自变量的值将为 false,就不会创建 元素。使用 && 运算符是 JSX 中常见的习惯用法,它可以根据其它表达式的值有条件地包含或排除子元素。这个习惯用法适用于 React,因为 React 会忽略 false 或 null 的子级,而不会为它们生成任何输出。
|
||||||
|
|
||||||
|
在 JSX 表达式中使用 JavaScript 表达式的时候,不限于前面例子中如字符串和布尔的基础类型值,也可以是对象类型的 JavaScript 值。实际上,使用对象、数组和函数值在 React 中很常见。例如在下面的函数中,JavaScript 和 CSS 的表达如下:
|
||||||
|
|
||||||
|
function list(items, callback) {
|
||||||
|
return (
|
||||||
|
<ul style={ {padding:10, border:"solid red 4px"} }>
|
||||||
|
{items.map((item,index) => {
|
||||||
|
<li onClick={() => callback(index)} key={index}>{item}</li>
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这里,函数使用对象字面量作为 <ul> 元素上 CSS 样式(style)的属性的值。注意,这里需要用到的是双大括号。<ul>元素有一个子元素,但该子元素的值是一个数组。输出的数组是通过在输入的数组上使用 map() 的映射方法来创建 <li> 子元素的。并且在这里,每个嵌套的 <li> 子元素都有一个 onClick 事件处理程序属性,其值是一个箭头函数。
|
||||||
|
|
||||||
|
如果我们将上面的 JSX 代码编译为标准的 JavaScript 代码,则会是以下的形式:
|
||||||
|
|
||||||
|
function list(items, callback) {
|
||||||
|
return React.createElement(
|
||||||
|
"ul",
|
||||||
|
{ style: { padding: 5, border: "dotted green 3px" } },
|
||||||
|
items.map((item, index) =>
|
||||||
|
React.createElement(
|
||||||
|
"li",
|
||||||
|
{ onClick: () => callback(index), key: index },
|
||||||
|
item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
React元素类型
|
||||||
|
|
||||||
|
另外,JSX 还有一个更重要的特性,就是定义 React 元素的类型。
|
||||||
|
|
||||||
|
所有 JSX 元素都以一个标识符开头,紧跟在开口角括号之后。如果该标识符的第一个字母是小写的,那么该标识符将作为字符串传递给 createElement()。但是,如果标识符的第一个字母是大写的,那么它将被视为实际标识符,并且该标识符的 JavaScript 值将作为第一个参数传递给 createElement()。
|
||||||
|
|
||||||
|
这意味着 JSX 表达式 <CustomButton/> 编译为 JavaScript 代码,将全局 CustomButton 对象传递给 React.createElement()。对于 React 来说,这种将非字符串值作为第一个参数传递给 createElement() 的能力可以用来创建组件。
|
||||||
|
|
||||||
|
在React中定义新组件的最简单方法是编写一个函数,该函数将 props 对象作为参数并返回 JSX 表达式。props 对象只是一个表示属性值的 JavaScript 对象,就像作为第二个参数传递给createElement() 的对象一样。例如,这里是我们的 article() 函数的另外一种写法:
|
||||||
|
|
||||||
|
function Article(props) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{props.title}</h1>
|
||||||
|
{ props.linebreak && <br /> }
|
||||||
|
<p>{props.content}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function article(className, title, content, linebreak=true) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{ linebreak && <br /> }
|
||||||
|
<p>{content}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这个新的 Article() 函数很像以前的 article() 函数。但它的名称以大写字母开头,并且只有一个对象作为参数。这可以使得它成为一个 React 的组件,这就意味着它可以用来代替 JSX 表达式中标准的 HTML 标记:
|
||||||
|
|
||||||
|
var article = <Article title="文章标题" content="文章内容"/>;
|
||||||
|
|
||||||
|
|
||||||
|
这个 <Article/> 元素在转译后如下:
|
||||||
|
|
||||||
|
var article = React.createElement(Article, {
|
||||||
|
title: "文章标题",
|
||||||
|
content: "文章内容"
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
这是一个简单的 JSX 表达式,但当 React 渲染它时,它会将第二个参数,也就是 props 对象,传递给第一个参数,也就是 Article() 函数,并将使用该函数返回的 JSX 函数代替 表达式。
|
||||||
|
|
||||||
|
另外,如果我们的一个模块中有多个组件的话,也可以用 dot notation 的方式,来表达模块中的组件。
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
var MyComponents = {
|
||||||
|
Calendar: function Calendar(props) {
|
||||||
|
return <div>一个{props.color}颜色的日历.</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function GreenCalendar() {
|
||||||
|
return <MyComponents.DatePicker color="绿" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
JSX 中对象表达式的另一个用途是使用对象扩展运算符一次指定多个属性。如果你编写了一组带有可能被重用的公共属性的 JSX 表达式,则可以通过将属性抽象定义为一个属性对象,并将其“扩展”到 JSX 元素中,来简化表达式。
|
||||||
|
|
||||||
|
var greeting = <div firstName="三" lastName="张" />;
|
||||||
|
|
||||||
|
var props = {firstName: '三', lastName: '张'};
|
||||||
|
var greeting = <div className = "greeting" {...props} />;
|
||||||
|
|
||||||
|
|
||||||
|
Babel 会将其编译为一个使用 _extends() 的函数,该函数将 className 属性与 props 对象中包含的属性相结合。
|
||||||
|
|
||||||
|
var greeting = React.createElement("div",
|
||||||
|
_extends({className: "greeting"}, props)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
今天,我们看到了在JavaScript中,如何通过JavaScript的扩展 JSX,在 JavaScript 中表达 DOM 元素。这样做有几点好处:
|
||||||
|
|
||||||
|
|
||||||
|
它可以更好地赋能以UI和事件驱动的开发,并且通过我们在上节讲到的 Babel 编译器,将相关的表达转译成 createElement() 的函数表达。再利用 createElement() 的函数调用,创建并返回包含相关元素属性定义的对象。最后再通过 render() 的方式将元素对象在浏览器中做渲染。同时,它可以和 JavaScript 和 CSS 的表达交叉使用,我们可以在 JSX 的表达中参入 JS 和 CSS 的表达。并且,除了基础类型的值,我们也可以使用数组、函数等对象的数据类型。
|
||||||
|
通过对 JSX 的使用,也有助于模块化和组件化的开发。
|
||||||
|
最后,从安全性上来看,JSX 的好处是所有的内容在渲染前都被转换成了字符串,这样也可以有效地防止我们在安全的一讲中提到的跨站脚本攻击(XSS),让我们的应用更安全。
|
||||||
|
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
最后,留给你一道思考题,其实在 JSX 之前,基于 JavaScript 就有很多的模版引擎,例如jQuery Template 和 Mustache.js 等。你觉得 JSX 和传统的 JavaScript 模版引擎有什么区别吗?各自的优劣势是怎样的?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
137
专栏/JavaScript进阶实战课/40Polyfill:通过Polyfill让浏览器提供原生支持.md
Normal file
137
专栏/JavaScript进阶实战课/40Polyfill:通过Polyfill让浏览器提供原生支持.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
40 Polyfill:通过Polyfill让浏览器提供原生支持
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在之前的课程中,我们提到过,用 JavaScript 写的程序不是在统一的环境中运行的。虽然我们知道现实中存在定义 ECMAScript 规范的组织 TC39,以及编写 HTML5 和 CSS 规范的组织 W3C(万维网联盟),但不同的浏览器厂商对 JavaScript 虚机的实现都会影响我们的程序在最终执行时的结果。因为虽然标准是存在的,但问题是执行这些标准的公司是独立于标准之外存在的,这就导致了同样的标准,在不同浏览器厂商和JS引擎的理解下,产生了不同的实现。
|
||||||
|
|
||||||
|
今天,我们就看看,如何能在不同的浏览器厂商对同一组较新的 JavaScript 和相关的 Web API 功能支持程度不一的情况下,通过创建一个 Polyfill,来解决原生支持问题。Polyfill 在英文中直译过来的意思就是“填缝剂”,我们可以把它理解成一种补丁。
|
||||||
|
|
||||||
|
造成原生支持问题的原因
|
||||||
|
|
||||||
|
首先,我们来看看浏览器为什么会存在原生支持问题。
|
||||||
|
|
||||||
|
原生支持问题其实一直都困扰着开发者,在互联网应用出现的早期,出现最多原生支持问题的便是 IE。这主要是由几方面的原因造成的:
|
||||||
|
|
||||||
|
|
||||||
|
第一,IE早期的版本没有自动更新,而且我们大多数人都没有手动更新软件的习惯,这也就造成了很多的用户在新版本的 IE 提供了更多新功能升级和 bug 修复的情况下,仍然停留在老版本的浏览器上;
|
||||||
|
第二,因为 Windows 的系统早期并不是和电脑捆绑售卖的,也就造成了很多人使用的是破解版的 Windows 系统,为了避免升级时影响盗版的使用,很多人也不会主动更新操作系统,这就进一步造成了人们对系统连带的浏览器版本更新的滞后;
|
||||||
|
第三,企业中使用的 IE 也是浏览器兼容性问题的重灾区。因为越是大的公司,越是“稳定”压倒一切,就越是需要 IT 管理员来集中地更新公司电脑操作系统和软件的版本,而对浏览器这种“病从口入”的连接互联网的窗口软件,更是安全防护的重点,需要集中更新,所以更是大大减缓了浏览器更新的速度。所以早期的网站和 Web 应用开发,主要的问题都集中在 IE6+ 的版本对新版的 JavaScript 原生支持的问题。
|
||||||
|
|
||||||
|
|
||||||
|
如今,随着 IE 退出了历史舞台,向下兼容的问题在逐渐减少,但是问题也不是一下子就完全消失了。因为除了 IE 的问题外,其它的浏览器提供商,如 Chrome、Safari、Mozilla、Opera 等也都是公司。既然是公司,那么它们之间就会存在竞争,从而争取更多的市场份额。
|
||||||
|
|
||||||
|
也正因为如此,他们有时会在新定义的 JavaScript 规则还比较模糊的时候加入自己的理解,而不是所有的浏览器都使用同一套新功能发布周期和标准。因为一些公司认为,当 JavaScript 规范中出现新功能时,他们更了解该如何实现,让开发者可以提前体验到新功能,这样也有助于对标准的验证,而另外一些浏览器则并不总是支持一些具有前瞻性的功能。
|
||||||
|
|
||||||
|
解决原生支持问题的方法
|
||||||
|
|
||||||
|
那么面对不同浏览器对新功能支持的不统一,如果我们想使用较新的 JavaScript 功能,有两个方法:
|
||||||
|
|
||||||
|
|
||||||
|
第一,是使用我们在前面提到过的 Babel.js,通过对 JavaScript 的转译,我们可以在提前用到新功能的同时,保证较老版本浏览器的支持;
|
||||||
|
第二个方法,就是使用 Polyfill,Polyfill 作为一个插件,它提供了较新浏览器的功能,但也提供了较旧版本的功能。
|
||||||
|
|
||||||
|
|
||||||
|
前面,我们讲过了代码转译的工具 Babel.js,那我们是不是可以用类似的工具解决所有的原生支持问题呢?
|
||||||
|
|
||||||
|
这里,我们需要注意的是,Babel 最主要的能力是让我们提前用上一些新的 JavaScript 语法功能,类似 let、const 这些变量和函数,但是它不能对 JavaScript 中一些新的功能做转译处理。比如转译没法支持数组上的 map 方法函数,也不能对 promise 做转译。这时,我们就需要结合使用到 Polyfill 了。
|
||||||
|
|
||||||
|
那我们什么时候该用 Polyfill,什么时候可以用转译器呢?
|
||||||
|
|
||||||
|
这里虽然没有明显的界定,但是有个大的原则,你可以参考:如果你想提前使用一个新的 JavaScript 语法,那么需要用到的是转译器;但如果你想实现一种新的功能或方法,那用到的可能会是Polyfill。
|
||||||
|
|
||||||
|
另外值得注意的是,虽然 Polyfill 是用 JavaScript 编写的,并且在 JavaScript 引擎中运行,但是它不只适用于 JavaScript 的补丁,HTML 和 CSS 的功能也可以使用 Polyfill 来解决原生支持的问题,甚至我们现在很多手动写的 Polyfill 都是 HTML 和 CSS 相关的补丁。这是因为,Babel 作为 JavaScript 的转译结合 Polyfill 的工具已经解决了大多数语法和功能上的问题。
|
||||||
|
|
||||||
|
前面讲到 Babel 的那节课,我们也提到过 ECMAScript 第6版是 JavaScript 的主要更新版本,这一版中添加了大量的新功能和语法,所以,如果你想支持 Internet Explorer,或者在其它不支持 ES6 的浏览器中使用最新的 ECMAScript 功能,那么通过使用 Babel,就可以系统性地解决这些问题。但是针对 HTML5 和 CSS3,市面上并没有一个像 Babel 一样的统一工具,因为相关的功能太多,且很多不是都在程序中需要用到的,所以大多是以独立的执行特定任务的补丁的形式出现的。
|
||||||
|
|
||||||
|
在标准和浏览器都不断升级的今天,跟踪不断发展的 HTML5 和 CSS 的支持可以说是一项非常艰巨的任务。比如我们想使用 CSS 动画来创造一些页面上的效果,想要知道哪些浏览器支持该功能,对于那些不懂这些代码的浏览器该怎么办,这些问题原本是一个很大的挑战。好在针对这个问题,有一个 html5please.com 的网站,提供了 HTML5 元素和 CSS3 规则的完整列表,并概述了浏览器支持以及列出了每个元素的任何 Polyfill。
|
||||||
|
|
||||||
|
Polyfill 的具体实现
|
||||||
|
|
||||||
|
但既然我们的重点是讲 JavaScript,我们还是用一个 JS 的例子来看看如何通过 Polyfill 来解决 JavaScript 原生支持问题。Polyfill 的运转机制很简单,当某些功能不被浏览器支持时,它提供了向下兼容的方式。
|
||||||
|
|
||||||
|
Polyfill 在编写的时候,通常都遵循一个模式,就是我们先判断脚本想要实现的功能是否已经被当前运行时的浏览器支持了。如果是,我们就不需要使用 Polyfill 了,如果不支持,那我们就需要 JavaScript 引擎来执行我们定义的补丁。下面,我们可以通过写一个 Polyfill,来更好地了解它的工作机制。
|
||||||
|
|
||||||
|
这里我们可以用数组中的 forEach 方法来举例。首先,我们来看看 forEach 的定义和用途。在定义上,当我们在数组上使用 forEach 方法的时候,需要传入一个回调函数。回调函数带有3个参数,第一个是必选,代表当前元素的值;第二个是可选,代表当前元素在数组中的索引;第三个也是可选,代表当前元素所属的数组对象。这里面比较常用的是前两个参数,所以也是我们尝试实现的重点。
|
||||||
|
|
||||||
|
array.forEach(function(currentValue, index, arr), thisValue)
|
||||||
|
|
||||||
|
|
||||||
|
下面我们再来看看 forEach 方法的用途。首先让我们先创建一个数组,我们称之为 oldArray。然后里面,我们加入三个地名,纽约、东京、巴黎。之后,让我们再创建一个空数组,newArray。我们可以通过遍历第一个数组的方式,将里面的元素推送到第二个数组中。这就是一个 forEach 的简单用例。
|
||||||
|
|
||||||
|
var oldArray = ["纽约", "东京", "巴黎"];
|
||||||
|
var newArray = [];
|
||||||
|
|
||||||
|
oldArray.forEach( function(item, index) {
|
||||||
|
newArray.push(index + "." + item);
|
||||||
|
}, oldArray);
|
||||||
|
|
||||||
|
newArray; // ['0.纽约', '1.东京', '2.巴黎']
|
||||||
|
|
||||||
|
|
||||||
|
通常在写 Polyfill 的时候,我们都会看功能本身是否已经被支持了。为了确定我们当前使用的浏览器是否支持 forEach 方法,我们可以使用开发者工具中的控制台,编写一段代码来测试下。
|
||||||
|
|
||||||
|
我们可以通过检查 forEach 方法是否在数组的 prototype 原型上的方式,来看这个功能是否存在。所以,接下来让我们测试下 forEach 是否返回 undefined,如果返回的结果是 undefined,就表示当前浏览器不支持这个方法,也就是说浏览器根本不知道它是什么;如果返回的结果是 true,则意味着 forEach 不等于 undefined,那么 forEach 在我当前版本的浏览器上是本地支持的。
|
||||||
|
|
||||||
|
Array.prototype.forEach !== undefined;
|
||||||
|
|
||||||
|
|
||||||
|
因为 forEach 的方法是从 ES6 开始就被支持的了,所以如果你运行上面这一段代码,大概率得到的结果会是 true,也就是说你应该可以看到浏览器对 forEach 方法的支持。但为了尝试手写 Polyfill 的过程,我们假设当前的浏览器太旧了,它不知道 forEach 函数,也未定义 forEach 方法,因此它不存在于当前使用的浏览器中。那么让我们手动写个补丁来实现这个功能吧。
|
||||||
|
|
||||||
|
下面,我们就可以正式编写 Polyfill 的功能了。首先,我们在建立这个Polyfill 的时候,第一步要看转参是不是一个函数。既然我们希望这个 Polyfill 能够在所有的数组原型上都能使用,那么我们就需要在 JavaScript 中访问 Array 对象,并在其原型上定义一个名为 forEach 的新函数方法。
|
||||||
|
|
||||||
|
这里有一点需要注意的是,因为我们现在的浏览器是支持 forEach 功能的,所以这么做其实就等于覆盖了原生支持的 forEach 功能。所以首先,我们需要判断用户传入的是不是一个函数参数。这里,我们可以使用 JavaScript 内置的 typeof 方法来实现对参数类型的检查。如果返回的结果不是函数类型的话,程序应该返回一条报错。这其实就是一种类型检查。
|
||||||
|
|
||||||
|
Array.prototype.forEach = function(callback, thisValue){
|
||||||
|
if (typeof(callback) !== "function") {
|
||||||
|
throw new TypeError(callback + "不是一个函数");
|
||||||
|
}
|
||||||
|
var arrayLength = this.length;
|
||||||
|
for (var i=0; i < arrayLength; i++) {
|
||||||
|
callback.call(thisValue, this[i], i, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldArray = ["纽约", "东京", "巴黎"];
|
||||||
|
var newArray = [];
|
||||||
|
|
||||||
|
oldArray.forEach( function(item, index) {
|
||||||
|
newArray.push(index + "." + item);
|
||||||
|
}, oldArray);
|
||||||
|
|
||||||
|
newArray; // ['0.纽约', '1.东京', '2.巴黎']
|
||||||
|
|
||||||
|
|
||||||
|
如果参数通过了类型检查,那么接下来,我们就要正式写 forEach 实现的部分了。
|
||||||
|
|
||||||
|
在这个部分中,我们首先需要获取的是数组的长度。因为 this 代表的是函数在调用时的主体,也就是说我们是通过数组来调用 forEach 方法的,所以在这里,我们可以通过 this.length 来获得数组的长度。
|
||||||
|
|
||||||
|
之后,我们可以通过一个 for 循环,来遍历数组中的元素,针对每个元素执行回调函数。在回调函数中,我们可以传入当前元素值、该元素的索引和数组三个参数。这里,为了 this 的引用是正确的,我们使用了 call() 方法,这样做是为了让 this 指向我们传入的正确的第二个参数对象。
|
||||||
|
|
||||||
|
同样,我们可以用前面的用例来测试,你会发现我们用来覆盖原始 forEach 功能的 Polyfill,达到了同样的效果。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过这一讲的学习,我们知道了如何使用 Polyfill 来解决原生支持的问题,也了解了它和转译工具的不同点和互补之处。如果只有使用,没有了解过它们的工作原理,这两个工具是很容易被混淆的。所以我在这里也再次强调下,加深一下理解,转译和 Polyfill 的共同点是它们的目的都是为了提供原生支持;而它们的区别在于支持的内容和实现的方式。
|
||||||
|
|
||||||
|
|
||||||
|
转译解决的主要是语法的原生支持问题,它的实现方式是通过将源代码转换成源代码,而不是机器码。
|
||||||
|
Polyfill 解决的主要是功能的原生支持问题,它的实现方式可以是通过在对象原型上增加补丁来支持相关功能。
|
||||||
|
|
||||||
|
|
||||||
|
转译和 Polyfill 并不是非此即彼的概念,相反,它们是高度互补的,大多情况下可以结合起来使用。
|
||||||
|
|
||||||
|
想必很多同学之前也都使用过 Polyfill,希望现在的你不仅会用,还可以掌握设计和编写一个 Polyfill 的能力。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
在平时的开发中你有用到 Polyfill 的场景吗?你主要使用它来解决哪些问题呢?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
111
专栏/JavaScript进阶实战课/41微前端:从MVC贫血模式到DDD充血模式.md
Normal file
111
专栏/JavaScript进阶实战课/41微前端:从MVC贫血模式到DDD充血模式.md
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
41 微前端:从MVC贫血模式到DDD充血模式
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
在前面“JS之器”的一个单元,我们介绍了为 JavaScript 开发赋能的工具。今天,我们来到了专栏的最后一个单元,也就是“JS之势”。在这个单元里,我们会看一下在 JavaScript 和前端开发中的一些趋势。这节课我们就来看看“微前端”这个近些年兴起的概念。
|
||||||
|
|
||||||
|
从微服务到微前端
|
||||||
|
|
||||||
|
在说微前端之前,我们先来看看启发微前端概念的微服务。
|
||||||
|
|
||||||
|
微服务是在 2011 年首次被提出的一种架构模式。在微服务的概念被提出之前,Web 服务的开发主要经历了 C/S 结架构和 SOA 结构两个阶段。下面我们先来看看 C/S 结构。
|
||||||
|
|
||||||
|
C/S结构
|
||||||
|
|
||||||
|
C/S 结构的全称是“客户机和服务器结构(Client Server)”,这种结构可以算是 Web 开发中早期的系统架构之一了。当时,大多的 Web 开发依赖的是开发者创建几个 HTML、CSS 和 JavaScript 文件,然后通过 SFTP 将开发完的程序上传到服务器,供客户端用户通过浏览器,经过网络来访问和下载相关资源。随着网站和 Web 应用程序规模的增加,简单的 C/S 结构已经不再能够处理大型企业 Web 应用程序的复杂性需求了。
|
||||||
|
|
||||||
|
SOA结构
|
||||||
|
|
||||||
|
这时,C/S 结构的演变——SOA,也就是面向服务的系统架构的概念,在 90 年代末诞生了。SOA 通过面向服务的体系结构设计,建立了一种分层体系结构设计方法。顶层是应用程序;中间层是服务;底层是后端,包含业务逻辑和数据库。
|
||||||
|
|
||||||
|
在 SOA 结构下,我们就可以看到组件化的设计思想了,因为在这种结构下,每个服务都可能被封装。每个服务本身是可重用的,既可以用于构建复合服务,也可以单独服务于特定的业务对象。并且它也可以具有不同级别的颗粒度,并提供一个抽象层,因此业务逻辑和数据不必对应用程序可见。总体而言,服务提供者和消费者两端的开发人员可以基于服务之间的合约进行独立的开发和相互的集成。
|
||||||
|
|
||||||
|
微服务结构
|
||||||
|
|
||||||
|
随着时间的推移,微服务的结构又在 2011 年前后诞生了,随后兴起。乍一看,它只是 SOA 的不断演变的结果,并与 SOA 架构有许多共同特点。例如,微服务结构的理念中也包含封装、颗粒度、抽象、契约协议等概念。不过不同之处在于,微服务更关注基于域驱动的限界上下文、协作和 DevOps。微服务的到来对业务架构、软件开发和软件工程都有着不同程度的影响。
|
||||||
|
|
||||||
|
从业务架构的角度来看,它是基于域驱动的设计,这样的设计更多关注的是面向业务对象而不是面向过程的设计。第二个是从软件开发的角度,传统的服务开发模式大多是基于贫血的域模型(ADM,Anemic Domain Model)的开发,而随着微服务和 DDD 的兴起,逐渐转为了充血的域模型(RDM,Rich Domain Model)。
|
||||||
|
|
||||||
|
贫血的域模型中一个典型的例子就是 MVC 的结构,可是说到这里,你可能会问,MVC 难道不是一个前端的开发架构,为什么说它是 Web 后端服务开发中的贫血模型呢?这是因为其实在后端开发中,也有三层的分层结构叫做 Repository、Service、Controller,这三层分别负责数据访问、业务逻辑和接口暴露。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在后端分层的开发中,数据对象、业务对象和展示对象都只包含数据,不包含业务逻辑,所以被称之为是贫血模式。而与之相对的充血模式,其实主要是指在业务逻辑层的充血。也就是在服务层,在 Service 类的基础上加入了 Domain 类,Domain 类中既包含了数据,也包含了逻辑;而 Service 类则被弱化,更多只负责调用逻辑、权限和事务控制等。
|
||||||
|
|
||||||
|
这里,你可能会问,那这样看来,DDD 听上去很玄乎,可从开发层面感觉只是把原本的 Service 类换成了 Domain 类,这对开发出来的服务能有什么影响呢?
|
||||||
|
|
||||||
|
其实在这里,主要的影响是设计理念和开发流程引起的。我们可以想象下,在传统的贫血模式下,服务的设计是被动的,几乎都谈不上设计,而更多的是基于前端的界面数据需求的响应,我们甚至可以把它称作是 SQL 驱动的开发。比如前端需要一个数据的展示,会把需求提给后端,后端开发这时来想什么样的 SQL 语句可以获取相关数据,然后创建或更改数据访问对象、业务对象和展示对象,最后以接口暴露的方式反馈给前端。
|
||||||
|
|
||||||
|
如果是一个并不复杂的应用系统,用这种模式开发还好,然而我们一旦面对较为复杂的业务逻辑的时候,这样的设计就会变得难以维护,因为任何一个细小的数据请求变化,都可能会产生新的代码,其中有很多可能是重复的代码。
|
||||||
|
|
||||||
|
而域驱动和 SQL 驱动的主要不同点在于,域驱动要求开发人员在一开始就要梳理清楚所有的业务架构,定义领域模型,以及模型中所包含的属性和方法。这些领域模型其实就等于是可以复用的业务中间层。所以面对新的功能需求开发时,都可以基于之前定义好的领域模型来完成。
|
||||||
|
|
||||||
|
除了在开发设计和开发流程上的转变外,微服务和 DDD 也从协作和软件工程的角度改变了开发的习惯。这种模块化的服务构建方法,允许将每个后端服务设计和开发为更小的构建模块,从而实现更大的系统灵活性。通过对 DevOps、持续集成和部署的支持,还可以减少开发的周期时间,提高运维效率。越来越多的软件公司和大型企业一直在推广和改造这种架构,然而,同样的想法在前端 Web 开发中尚未成熟。
|
||||||
|
|
||||||
|
微前端结构
|
||||||
|
|
||||||
|
但随着时间的推移,到了 2016 年,微前端的概念就出现了。如果说微服务是打破了后端的单体结构一样,那么微前端的概念则是打破了前端的单体结构。这样,前端的不同模块也可以被组件化地基于页面需求来加载和相互交互。此外,微前端也允许具有不同技术、技能和工具集的不同前端团队可以在每个单独封装的业务领域中独立开发一组功能。
|
||||||
|
|
||||||
|
微前端的设计和实现
|
||||||
|
|
||||||
|
下面,在正式说到微前端之前,我们再来看看前端系统结构的演变。
|
||||||
|
|
||||||
|
早期的网站更多是“多页面应用程序”,这意味着终端用户访问的是网站的每个独立页面,页面间的跳转是通过点击超链接的方式操作的。每当跳转的操作被调用的时候,页面就会刷新。网站的内容和页面创建通常是由 CMS 提供的支持,CMS 代表内容管理系统,用于创建、编辑和发布动态内容,尤其是在网络上。
|
||||||
|
|
||||||
|
早年,网络上曾出现过很多流行的 CMS 工具,如 WordPress 或 Drupal。CMS 系统有两个不同的用户界面,一个用户界面可供公众访问,另一个界面可供后台作者和编辑访问。业务逻辑和内容主要在后端呈现,输出是包含 HTML 格式信息的网页,供公众查看。
|
||||||
|
|
||||||
|
CMS 的工作流程是,内容作者将在后台选择内容创作的模板和组件,一旦完成页面的构建和内容填充,作者将发布页面,页面将被发送。通常,CMS 会结合 CDN(Content Delivery Network)一起使用,通过缓存,加快页面和资源加载时间。当然,这种经典的 CMS 方法至今仍被广泛使用,例如现在使用 WordPress 创建个人博客的人还有很多。CMS 工具流行的一个原因是,选择用 CMS 来开发主要是着重信息更新,不需要大量的业务逻辑交互的页面。
|
||||||
|
|
||||||
|
随着 Ajax 的兴起,以及后来的 HTML5 和 CSS3 技术的出现,网站的互动性越来越强,丰富的互联网应用程序也不断涌现。由于更多的用户交互,SPA(单页应用程序)的技术就随之兴起了,在 Web 开发中得到了越来越广泛的应用。虽然在单页应用程序中,仍然可以使用 CMS,但它的功能将仅限于管理内容而不是创建HTML页面。在这种情况下,前端应用程序承载了页创建和展示的任务,而 CMS 只负责内容的提供。通常这样的 CMS 被称为无头 CMS(headless CMS)。
|
||||||
|
|
||||||
|
SPA 不仅限于 PC 端,还可以用于移动端和其他嵌入式系统,并且可能会根据客户端类型对内容进行一些更改,在这种情况下,还可能需要添加一个 BFF 的适配层。当然 BFF 的作用不仅在适配,它也可以用于 API 层的复合或聚合、服务的认证或授权,或者在遗留系统之上构建微服务。
|
||||||
|
|
||||||
|
单页应用程序的另一个问题是搜索引擎优化。搜索引擎通常基于 URL 链接搜索内容。然而,对于单页应用程序,搜索引擎不再能够轻松找到页面,因为当用户从一个部分导航到另一个部分时,没有 URL 更新。谷歌开发了强大的算法,甚至可以抓取 SPA 页面。然而,谷歌并不是世界上唯一使用的搜索引擎,仍然有许多搜索引擎无法抓取服务器端渲染和客户端渲染的 HTML 页面。
|
||||||
|
|
||||||
|
针对这个问题,目前最常用的解决方案是同时使用客户端和服务器端渲染,这意味着前端和后端将共享相同的模板文件,并使用单独的模板引擎来渲染包含数据的页面。这种与 Node.js 结合的模式被称为同构 JavaScript(isomorphic JavaScript)。
|
||||||
|
|
||||||
|
而 Web 应用系统从 SPA 进一步演变到微前端的过程中,有几点是我们需要特别注意的。微前端的设计通常包括4层结构的考虑,分别是:系统层结构、应用层结构、模块层结构和编码层结构。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
系统层的架构:涉及了我们希望微前端如何与其它系统或应用程序(例如后端或后台系统)交互。后端系统需要某种类型的集成,通常涉及使用微服务、BFF、API 契约、用于开发测试的 mock API 和进行身份验证的 OAuth 等。
|
||||||
|
|
||||||
|
应用层的架构:包括我们用来构建微前端应用程序本身的框架、设计系统与周围的实用工具、库和程序。
|
||||||
|
|
||||||
|
模块层的架构:涉及了用作应用程序中主应用和子应用构建块的所有组件和模块。比如 Web Components 的技术对于微前端开发非常重要,因为它主要提供了前端开发中的模块化支持。但在使用 Web Components 的时候,要特别注意的是,它不是W3C定义的官方标准,并且也存在兼容性的问题,市面上使用 Web Components 开发通常都基于如 Lit 或 Stencil 这样的工具来降低使用风险。
|
||||||
|
|
||||||
|
编码层的结构:这一层次的体系结构涉及开发流程(包括存储库管理、代码合并、代码提交和拉取请求等)、代码质量、编码标准等。这里,这些是我们在微前端开发中应统一考虑的原则。
|
||||||
|
|
||||||
|
基于这4层的微前端结构,我们可以重点看看应用层的 App 容器、配置、注册和发现、中央路由、生命周期管理,以及编码层的开发流程、构建和部署流程。
|
||||||
|
|
||||||
|
App注册发现和路由
|
||||||
|
|
||||||
|
尽管微前端架构是一个去中心化的概念,但一个中央的 App 容器对于创建所有组件协同工作的一致体验仍然是至关重要的。除了定义了一个 App 容器以外,定义一个子应用注册和发现的机制也很重要,这和微服务的注册和发现类似。一旦有了子应用的注册和发现机制,中央 App 容器则可以通过路由来发现,并将用户重定向到正确的内容所在位置。
|
||||||
|
|
||||||
|
App生命周期管理
|
||||||
|
|
||||||
|
除了上述机制外,每个子 App 自身的生命周期管理也是很重要的。在一个 App 的生命周期中,需要处理的任务包括了:从容器中插入、更新或删除的模块,或处理组件的加载、更新、卸载和错误处理等关键任务。这个过程从子 App 注册器获取配置开始,host 会检索配置的详细信息,当发生 URL 更改时,更改事件将根据配置详细信息触发现有子 App 的卸载和新的子 App 的装载。当 URL 更改时,新的子 App 将加载到 host。
|
||||||
|
|
||||||
|
App开发和部署管理
|
||||||
|
|
||||||
|
在任何开发工作中,一个高效的开发流程都很重要,在微前端开发中更是如此。为了保持高效的开发管道,可以通过我们在上一单元“JS 之器”中讲到的多种工具(如Jest、ESLint)结合来检查几个关键点,以实现更好的代码功能、结构、格式以及质量检查。同样的,我们可以在流程中加入 NPM 或 Yarn 等软件包的管理和依赖管理工具,通过 Babel 来对代码进行编译,以及通过 webpack 的工具对应用进行打包,这些都可以在构建和部署中使用来提高管理的效率。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
从这一讲中,我们看到了微前端的出现离不开在它之前出现的微服务以及基于领域的设计模型。但是在前端,不同于后端的点在于,前端并不是服务的提供方,而是消费者。所以微前端虽然受到了微服务的启发,都是强调基于业务领域的设计、组件的封装和模块化的开发,但还是有着使用场景上的本质不同。
|
||||||
|
|
||||||
|
同时,微前端也不是完全的去中心化,因为我们还是需要一个主 App 容器负责子 App 的加载。在子 App 间,不同于单体设计,这里我们需要花更多的精力来保证子 App 的注册和发现,路由以及生命周期的管理。而且越是去中心化,我们越是需要团队间有一套“共识机制”保证开发和部署流程的管理。但是这样带来的好处是,我们可以在子团队间有更大的开发自由,同时,在规模化的情况下,可以保证协作的效率。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
在微前端中,每个组件都是独立封装的,你知道如何实现它们之间的通信吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
126
专栏/JavaScript进阶实战课/42大前端:通过一云多端搭建跨PC_移动的平台应用.md
Normal file
126
专栏/JavaScript进阶实战课/42大前端:通过一云多端搭建跨PC_移动的平台应用.md
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
42 大前端:通过一云多端搭建跨PC_移动的平台应用
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
如今的 JavaScript 早已不只是可以满足 Web 开发,更是可以用于后端服务器,甚至数据库相关的开发。在上一讲,我们聊到了“微”前端,今天,我们再来看看“大”前端这种前端驱动的开发模式和传统的开发模式有什么不一样,以及如何利用大前端来实现前后端全部所需要的开发。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
前端应用
|
||||||
|
|
||||||
|
首先,我们来看看在我们最熟悉的前端开发中,如何通过 JavaScript 语言来满足不同端的应用的开发需求。在说到解决方案之前,我们先来看看移动开发的几种模式,以及它们的优劣势。
|
||||||
|
|
||||||
|
第一种是原生开发,比如苹果的应用,我们通常会使用 Swift 来开发,而对于安卓的应用,我们通常会使用 Kotlin 来开发。
|
||||||
|
|
||||||
|
原生开发有这样几点好处:
|
||||||
|
|
||||||
|
|
||||||
|
对于终端用户来说,它的体验会更顺畅,因为一般在 App 包下载安装的过程中,会一次性下载初始化的资源包,再加上加载过程的预处理,就减少了动态渲染的压力;
|
||||||
|
对于开发者来说,原生的 App 有强大的开发工具,从开发到打包再到发布,都有着很成熟的一条龙的流水线,而不像在 Web 应用的开发中,需要处理各种的原生支持问题、浏览器兼容问题等。
|
||||||
|
|
||||||
|
|
||||||
|
但是这种开发模式也不是没有短板,其中比较明显的就是在开发的过程中,作为开发者需要同时掌握 Swift、Kotlin 和 JavaScript 三种语言,再加上测试和应用商店审核等工作,可以说这样的工作量是成倍增加的,所以三端通常是由不同团队完成的。
|
||||||
|
|
||||||
|
第二种是 Web 应用,对于 Web 应用来说,程序是在浏览器中运行的。
|
||||||
|
|
||||||
|
Web 应用有这样几点好处:
|
||||||
|
|
||||||
|
|
||||||
|
大大减少了用户手机内存空间;
|
||||||
|
避免了 App 在部分区域的商店不支持的问题,用户需要访问相关应用的时候,只需要打开浏览器,输入相关的应用地址,就可以浏览相关信息;
|
||||||
|
对开发者来说,大大避免了漫长的应用商店审核流程,缩短了开发和发布周期。
|
||||||
|
|
||||||
|
|
||||||
|
当然,它的劣势也很明显:
|
||||||
|
|
||||||
|
|
||||||
|
从终端用户的体验上,Web 应用的体验是逊色于原生 App 的,毕竟浏览器的渲染还是比不上原生 App 的丝滑体验;
|
||||||
|
从用户体验上来说,用户需要输入地址才能访问相关的 App,虽然 Web 应用也支持生成快捷图标到桌面,或者移动浏览器也支持书签的功能,但是从使用习惯上来说,用户并没有相关的习惯,所以对于用户粘性很高、使用频率也比较高的应用来说,Web 应用就显得不是那么合适了;
|
||||||
|
对于品牌来说,想要给用户一个值得信赖的体验,一个可以下载的原生应用也是比一个只能访问的 Web 应用的 URL 看上去更加靠谱的。
|
||||||
|
|
||||||
|
|
||||||
|
第三种是混合开发模式,在混合开发模式下,顾名思义,就是 App 本身是通过原生的方式发布的,但是里面从不同程度嵌入了 Web 应用的混合应用。
|
||||||
|
|
||||||
|
首先,我们先来说说优势:
|
||||||
|
|
||||||
|
|
||||||
|
这样的开发模式下,让应用“看上去”更加原生了,毕竟它是“借壳上架”到应用商店的;
|
||||||
|
这样的开发模式下,内容的更新变得更加容易了,因为在一层原生应用的壳下,内部的应用实际是动态加载的 Web 应用,这样每次在更新的时候就可以采用“热更新”,而不需要重新对应用进行打包、再次审核及发布。
|
||||||
|
|
||||||
|
|
||||||
|
当然,这样做,也会有相关的问题:
|
||||||
|
|
||||||
|
|
||||||
|
应用商店也不傻,如果大家都只做一个壳,然后发布到应用商店,那这个审核机制就形同虚设了,所以通常一个应用的原生部分低于一定比例的时候,是无法通过审核的;
|
||||||
|
即使通过了审核,它的体验也会比较差,因为 App 本身就是一个应用,内部再调用浏览器来渲染一个 Web 应用,这样的体验通常是低于原生应用,甚至还不如直接在浏览器访问的 Web 应用。所以对于开发的应用,特别是游戏类的,这种方式肯定是不适合的。
|
||||||
|
|
||||||
|
|
||||||
|
那么为了解决上面的问题,在开发中,有没有什么解决方案呢?
|
||||||
|
|
||||||
|
在早期,曾经有一段时间,前端试图通过渐进式 Web 应用(PWA)的方式来代替原生应用。PWA 的概念是在 2016 年谷歌的 I/O 大会上被提出来的,它的核心理念是在 Web 应用的基础上,通过一系列的技术实现,来提高用户的粘性、增加响应、增强离线消息的可靠性。
|
||||||
|
|
||||||
|
为了能够给用户更加类原生的体验,这里用到了 Notification/Web Push API、Web Manifest、 Service Worker 等等。这些功能可以让 Web 应用也实现消息推送、形成桌面应用和离线功能等。但是,PWA 虽然在开发者的圈子中激起了很大的热情,但是对于终端用户来说,并没有很买单,对于大多数的手机平板用户来说,人们的选择还是下载原生 App 居多,但是在 PC 桌面,倒是大多数的用户都形成了用浏览器而尽量不安装原生应用的习惯。
|
||||||
|
|
||||||
|
既然通过开发驱动去改变用户的使用习惯是不可能的,那么开发者又换了一个思路,就是怎么在保持原生的基础上,能够选择 JavaScript 作为开发语言。
|
||||||
|
|
||||||
|
这时,在 2015年,由 Meta 在同一时期推出的 React Native 就逐渐变得流行起来。它可以允许开发者同时用一套 JavaScript 代码开发出 iOS、Android 和 Web 应用,以及基于 Android 的电视应用。也就是说,作为开发者,不再需要同时学习几种语言进行前端的移动开发。当然,随着 React Native 的流行,后面又相继出现了基于 React Native 的 Windows 和 macOS 应用开发模式。
|
||||||
|
|
||||||
|
所以,同一时期,2013 年推出的 Electron 也逐渐流行了起来,开发者可以在 Windows 和 macOS 应用的基础上,开发 Linux 的桌面应用。再往后,随着小程序的出现,国内也出现了基于 Vue.js 的 uni-app,它可以在原生和 Web 应用的基础上,加上对小程序的支持。
|
||||||
|
|
||||||
|
Web 服务
|
||||||
|
|
||||||
|
说完了前端,我们再来看看中间层。
|
||||||
|
|
||||||
|
通常,我们的网站需要一个 Web 应用框架,用来创建前端的页面,并且提供相关的数据给到前端渲染。为了达到这个目的,Web 应用最核心需要支持的功能是路由(routing)、中间件(middleware)和模版系统(template engine)。
|
||||||
|
|
||||||
|
路由的目的是能够根据 URL 来解析用户请求访问的页面;中间件的目的是根据前端的请求做出反馈,并且调用下一个中间件;模版引擎的作用是根据定义好的 HTML 模版和变量参数,对页面进行渲染。目前,市面上比较流行的 Web 服务器端的框架是 Express.js,可以满足我们上面说到的几个核心功能。
|
||||||
|
|
||||||
|
同时,如果我们展开来看中间件的功能,会进一步发现几个需要的核心子功能:
|
||||||
|
|
||||||
|
|
||||||
|
作为服务的提供方,在中间层除了构建页面外,也需要对应不同的前端,一云多端地提供按照不同的需求适配的 API;
|
||||||
|
在面对中间件下面的不同类型的数据库或后端系统,也需要提供一层聚合,让转化后的数据格式可以满足前端的需求;
|
||||||
|
从安全的角度来看,API 的创建需要一系列的鉴权。
|
||||||
|
|
||||||
|
|
||||||
|
为了满足这些条件,通常在服务器端需要一个相关的平台来完成相关的功能,例如 Apollo Server 便是一个可以和 Express.js 结合提供类似服务的平台。
|
||||||
|
|
||||||
|
数据的存储和查询
|
||||||
|
|
||||||
|
前面,我们说完了前端应用、中间件和 Web 服务 API,那么为了创建一个完整的 Web 应用系统,离不开数据本身。所以最后,我们再来看下数据的存储和查询。
|
||||||
|
|
||||||
|
首先,我们需要一个数据库来存储前端产生的数据。数据库可以大致分为两类,一类是关系型数据库(RDBMS),另外一类是非关系型数据库(NoSQL)。在服务器端的 JavaScript 中,有基于 JS 引擎 SpiderMonky 来实现的 MongoDB,它是一个类文件型的非关系型数据库。通过MongoDB,我们可以对应用产生和依赖的数据进行存储。因为它天然就是用类 JSON 的格式来做数据文件存储的,所以非常适用于 Web 应用的开发。
|
||||||
|
|
||||||
|
我们知道在前端开发中,当我们想要查询数据库中的信息时,需要通过一种查询语言。在传统的查询语言中,特别是在关系型的数据库中,我们通常使用的是 select from 这样的查询请求格式,返回的是一张表;而一种叫做 GraphQL 的查询请求和返回的格式,则更像是 JSON 或对象的字面量表达。
|
||||||
|
|
||||||
|
换句话说,它的使用更符合我们前端开发的直觉。GraphQL 便是在 2012 年发展起来的,当时 Meta 内部开发了一个 GraphQL 的项目,随后,在2015年对外公布,之后在 2018 年从 Meta 移到了独立的组织来管理。
|
||||||
|
|
||||||
|
GraphQL 有几大特点:
|
||||||
|
|
||||||
|
|
||||||
|
从对 JSON 格式的使用上,我们可以看出,GraphQL 是一种更适用于 API 服务的查询语言,也是使用现有数据完成这些查询的运行时。 GraphQL 为 API 中的数据结构提供了完整且易于理解的描述,使客户端能够准确地表达想要查询的对象。
|
||||||
|
GraphQL 的另外一大特点是,它用到了我们在前面 JavaScript 类型检查中提到的类型系统。这样可以更好地对输入和输出的数据类型有所定义和规范。
|
||||||
|
在传统的 REST API 中,我们有时需要通过几个请求才能拿到一条完整的返回信息,而通过 GraphQL,我们可以在一条信息中包含所需的全部信息,这样大大减少了反复的请求。
|
||||||
|
|
||||||
|
|
||||||
|
在传统的开发中,前后端产生分歧是常有的事,其中很大的原因就是后端很难站在前端的角度想如何能提供面向业务对象的数据;在数据服务于业务的情况下,应该如何保证类型系统的规范;又或者是应该如何让请求的数量减少,增加每次服务调用的效率。而在大前端的概念下,一切的系统设计都是服务于前端用户的,在这样重业务驱动的模式下,可以倒逼后端更好地满足前端开发的需求。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过这一讲的学习,我们可以看到,从前端应用的开发,到服务器端以及 API 开发,再到后端的数据存储和查询,我们几乎都可以通过 JavaScript 和相关的工具来完成。
|
||||||
|
|
||||||
|
从数据的存储和查询,我们可以看到两个关键词,就是 JSON 和 GraphQL。JSON 作为一种数据存储结构,运用在前端驱动的开发中,可以赋能业务数据对象和逻辑的实现;而 GraphQL 的出现,也更符合在网络环境中生成的数据往往是非线性的、网状的数据的查询,在这一点上,它有别于传统的、规范的、表格式的数据查询。所以大前端的出现,可以让数据的存储和数据的获取方式更好地服务于前端的交互和业务的需求。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
今天,我们提到大前端,说到它最核心的优势是可以通过前端驱动,让 API 和数据更高效地服务于前端用户和业务的需求。但它的好处却也不止于此,从另外一个角度来看,它通过 JavaScript 一种核心技术,满足了前中后台的开发需求,也等于起到了降本增效的作用。但除此之外,你还能想到它的其它优势或者一些短板吗?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的观点、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
129
专栏/JavaScript进阶实战课/43元编程:通过Proxies和Reflect赋能元编程.md
Normal file
129
专栏/JavaScript进阶实战课/43元编程:通过Proxies和Reflect赋能元编程.md
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
43 元编程:通过Proxies和Reflect赋能元编程
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
今天,我们来到了这个单元最后的一课。前面的两节课中,我们分别学习了微前端和大前端“一大一小”两个趋势,今天我们再来看看“元”编程。那么,元编程中的“元”代表什么呢?“元”有“之上”或“超出一般限制”的意思。听上去,比较玄乎,实际上,要理解和使用元编程,并不难,甚至你可能每天都在用,只是没有觉察到而已。所以今天,就让我们来一步步了解下元编程的概念及使用。
|
||||||
|
|
||||||
|
在 JavaScript 中,我们可以把元编程的功能分为几类:第一类是查找和添加对象属性相关的功能;第二类是创建 DSL 这样的特定领域语言;第三类就是可以作为代理用于对象的装饰器。今天,就让我们一一来看一下。
|
||||||
|
|
||||||
|
对象属性的属性
|
||||||
|
|
||||||
|
1. 对象属性的设置
|
||||||
|
|
||||||
|
首先,我们先来看下对象相关属性的查找和添加。我们都知道 Javascript 对象的属性包含了名称和值。但是另外,我们需要了解的是,每个属性本身也有三个相关的属性,它们分别为可写属性(writable)、可枚举属性(enumerable)以及可配置属性(configurable)。
|
||||||
|
|
||||||
|
这三个属性指定了该属性的行为方式,以及我们可以使用它们做什么。这里的可写属性指定了属性的值是否可以更改;可枚举属性指定了属性是否可以由 for/in 循环和 Object.keys() 方法枚举;可配置属性指定了是否可以删除属性或更改属性的属性。
|
||||||
|
|
||||||
|
这里,需要注意的是,我们自定义的对象字面量和通过赋值定义的对象属性都是可写、可枚举和可配置的,但 JavaScript 标准库中定义的很多对象属性都不是。从这里,你应该可以看出,只要你使用过 for/in,那么,恭喜你,基本你已经使用过元编程开发了。
|
||||||
|
|
||||||
|
那么对属性的查询和设置有什么用呢?这对于很多第三方库的开发者来说是很重要的,因为它允许开发者向原型对象中添加方法,而且也可以像标准库中的很多内置方法一样,将它们设置成不可枚举;同时,它也可以允许开发者“锁定”对象,让属性定义无法被更改或删除。下面,我们就来看看可以为 JavaScript 第三方库的开发赋能的查询和设置属性的 API。
|
||||||
|
|
||||||
|
我们可以将属性分为两类,一类是“数据属性”,一类是“访问器属性”。如果我们把值、getter、setter 都看做是值的话,那么,数据属性就包含了值、可写、可枚举和可配置这4个属性;而访问器属性则包含了 get、set、可枚举和可配置这4个属性。其中可写、可枚举和可配置属性是布尔值,get 和 set 属性是函数值。
|
||||||
|
|
||||||
|
我们可以通过 object.getOwnPropertyDescriptor() 来获取对象属性的属性描述,顾名思义,这个方法只适用于获取对象自身的属性,如果要查询继承属性的属性,就需要通过 Object.getPrototypeOf() 的方法来遍历原型链。如果要设置属性的属性或者使用指定的属性创建新属性,就需要用到 Object.defineProperty() 的方法。
|
||||||
|
|
||||||
|
2. 对象的可延展性
|
||||||
|
|
||||||
|
除了对对象的属性的获取和设置外,我们对对象本身也可以设置它的可延展性。我们可以通过 Object.isExtensible() 让一个对象可延展,同样的,我们也可以通过 Object.preventExtensions() 将一个对象设置为不可延展。
|
||||||
|
|
||||||
|
不过这里需要注意的是,一旦我们把一个对象设置为不可延展,我们不仅不可以在对象上再设置属性,而且我们也不可以再把对象改回为可延展。还有就是这个不可延展只影响对象本身的属性,对于对象的原型对象上的属性来说,是不受影响的。
|
||||||
|
|
||||||
|
对象的可延展性通常的作用是用于对像状态的锁定。通常我们把它和属性设置中的可写属性和可配置属性结合来使用。在 JavaScript 中,我们可以通过 Object.seal() 把不可延展属性和不可配置属性结合;通过 Object.freeze() 我们可以把不可延展、不可配置和不可写属性结合起来。
|
||||||
|
|
||||||
|
对于 JavaScript 第三方库的编写来说,如果库将对象传递给库的回调函数,那么我们就可以使用 Object.freeze() 来防止用户的代码对它们进行修改了。不过需要注意的是,这样的方法可能对 JavaScript 测试策略形成干扰。
|
||||||
|
|
||||||
|
3. 对象的原型对象
|
||||||
|
|
||||||
|
前面,我们介绍的 Object.freeze() 、 Object.seal() 和属性设置的方法一样,都是仅作用于对象本身的,都不会对对象的原型造成影响。我们知道,通过 new 创建的对象会使用创建函数的原型值作为自己的原型,通过 Object.create() 创建的对象会使用第一个参数作为对象的原型。
|
||||||
|
|
||||||
|
我们可以通过 Object.getPrototypeOf() 来获取对象的原型;通过 isPrototypeOf() 我们可以判断一个对象是不是另外一个对象的原型;同时,如果我们想要修改一个对象的原型,可以通过 Object.setPrototypeOf()。不过有一点需要注意的是,通常在原型已经设置后,就很少被改变了,使用 Object.setPrototypeOf() 有可能对性能产生影响。
|
||||||
|
|
||||||
|
用于DSL的模版标签
|
||||||
|
|
||||||
|
我们知道,在 JavaScript 中,在反引号内的字符串被称为模板字面量。当一个值为函数的表达式,并且后面跟着一个模板字面量时,它会变成一个函数被调用,我们将它称之为“带标签的模板字面量”。
|
||||||
|
|
||||||
|
为什么我们说定义一个新的标签函数,用于标签模板字面量可以被当做是一种元编程呢?因为标签模板通常用于定义 DSL,也就是域特定语言,这样定义新的标签函数就如同向 JavaScript 中添加了新的语法。标签模板字面量已被许多前端 JavaScript 库采用。GraphQL 查询语言通过使用 gql`` 标签函数,可以使查询被嵌入到 JavaScript 代码中。Emotion 库使用 css`` 标签函数,使 CSS 样式同样可以被嵌入到 JavaScript 中。
|
||||||
|
|
||||||
|
当函数表达式后面有模板字面量时,该函数将被调用。第一个参数是字符串数组,后面是零或多个其它参数,这些参数可以具有任何类型的值。参数的数量取决于插入到模板字面量的值的数量,模板字面量的值始终是字符串,但是标签模板字面量的值是标签函数返回的值。它可能是一个字符串,但当使用标签函数实现 DSL 时,返回值通常是一个非字符串数据结构,它是字符串的解析表示。
|
||||||
|
|
||||||
|
当我们想将一个值安全地插入到 HTML 字符串中时,模版会非常得有用。我们拿 html`` 为例,在使用标签构建最终字符串之前,标签会对每个值执行 HTML 转义。
|
||||||
|
|
||||||
|
function html(str, ...val) {
|
||||||
|
var escaped = val.map(v => String(v)
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("'", "'"));
|
||||||
|
var result = str[0];
|
||||||
|
for(var i = 0; i < escaped.length; i++) {
|
||||||
|
result += escaped[i] + str[i+1];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var operator = "&";
|
||||||
|
html`<b>x ${operator} y</b>` // => "<b>x & y</b>"
|
||||||
|
|
||||||
|
|
||||||
|
下面,我们再来看看 Reflect 对象。Reflect 并不是一个类,和 Math 对象类似,它的属性只是定义了一组相关的函数。ES6 中添加的这些函数都在一个命名空间中,它们模仿核心语言的行为,并且复制了各种预先存在于对象函数的特性。
|
||||||
|
|
||||||
|
尽管 Reflect 函数没有提供任何新功能,但它们确实将这些功能组合在一个 API 中方便使用。比如,我们在上面提到的对象属性的设置、可延展性以及对象的原型对象在 Reflect 中都有对应的方法,如 Reflect.set()、Reflect.isExtensible() 和 Reflect.getPrototypeOf(),等等。下面,我们会看到 Reflect 函数集与 Proxy 的处理程序方法集也可以一一对应。
|
||||||
|
|
||||||
|
Proxy 和 Reflect
|
||||||
|
|
||||||
|
在 ES6 和更高版本中提供的 Proxy 类可以算是 JavaScript 中最强大的元编程功能了。它允许我们编写改变 JavaScript 对象基本行为的代码。我们在前面提到的 Reflect API 是一组函数,它使我们可以直接访问 JavaScript 对象上的一组基本操作。当我们创建 Proxy 对象时,我们指定了另外两个对象,目标对象和处理程序对象。
|
||||||
|
|
||||||
|
var target = {
|
||||||
|
message1: "hello",
|
||||||
|
message2: "world",
|
||||||
|
};
|
||||||
|
var handler = {};
|
||||||
|
|
||||||
|
var proxy = new Proxy(target, handler);
|
||||||
|
|
||||||
|
|
||||||
|
生成的代理对象没有自己的状态或行为。无论何时对其执行操作(读取属性、写入属性、定义新属性、查找原型、将其作为函数调用),它都会将这些操作分派给处理程序对象或目标对象。代理对象支持的操作与 Reflect API 定义的操作相同。Proxy 的工作机制是,如果 handler 是空的,那么代理对象只是一层透明的装饰器。所以在上面的例子中,如果我们执行代理,那么它返回的结果就是目标对象上本来自有的属性。
|
||||||
|
|
||||||
|
console.log(proxy.message1); // hello
|
||||||
|
console.log(proxy.message2); // world
|
||||||
|
|
||||||
|
|
||||||
|
通常,我们会把 Proxy 和 Reflect 结合起来使用,这样的好处是,对于我们不想自定义的部分,我们可以使用 Reflect 来调用对象内置的方法。
|
||||||
|
|
||||||
|
const target = {
|
||||||
|
message1: "hello",
|
||||||
|
message2: "world",
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = {
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
if (prop === "message2") {
|
||||||
|
return "Jackson";
|
||||||
|
}
|
||||||
|
return Reflect.get(...arguments);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var proxy = new Proxy(target, handler);
|
||||||
|
console.log(proxy.message1); // hello
|
||||||
|
console.log(proxy.message2); // Jackson
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
通过今天这节课,我们看到了 JavaScript 对象的属性本身也带有可以查找和添加的属性。同时,我们学习了,JavaScript 定义的函数允许我们遍历对象的原型链,甚至更改对象的原型。
|
||||||
|
|
||||||
|
标签模板字面量是一种函数调用语法,我们可以用它来定义新的标签函数,这样做有点像向语言中添加新的字面量语法。通过定义一个解析其模板字符串参数的标签函数,我们就可以在JavaScript 代码中嵌入 DSL。标签函数还提供对字符串字面量的原始、非转义形式的访问,其中反斜杠不带有任何特殊含义。
|
||||||
|
|
||||||
|
最后,我们又看了 Proxy 类和相关的 Reflect API。Proxy 和 Reflect 允许我们对 JavaScript 中的对象的基本行为进行低级控制。Proxy 对象可以用作可选的、可撤销的包装器,以改进代码封装,还可以用于实现非标准对象行为(如早期 Web 浏览器定义的一些特殊情况下的 API)。
|
||||||
|
|
||||||
|
思考题
|
||||||
|
|
||||||
|
我们知道 Symbol 对象的属性的值可以用于定义对象和类的属性或方法的名称。这样做可以控制对象如何与 JavaScript 语言特性和核心库交互。那么,你觉得 Symbol 在这种使用场景下算不算是一种元编程呢?
|
||||||
|
|
||||||
|
欢迎在留言区分享你的观点、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
67
专栏/JavaScript进阶实战课/结束语JavaScript的未来之路:源于一个以终为始的初心.md
Normal file
67
专栏/JavaScript进阶实战课/结束语JavaScript的未来之路:源于一个以终为始的初心.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
结束语 JavaScript的未来之路:源于一个以终为始的初心
|
||||||
|
你好,我是石川。
|
||||||
|
|
||||||
|
今年,是不太寻常的一年。这一年,我们的思想都是比较割裂的,可能大家很难对某件事情有很强烈的共识。但是,我觉得有一点,大家的感触都差不多,就是如果我们给今年一个关键词叫做“不确定性”的话,很少会有人否认。
|
||||||
|
|
||||||
|
每个人都有认知这个世界的方式,我觉得对于我而言,JavaScript 作为一门语言就是我用来认知这个世界、去适应不确定性的方式。我们从这个专栏一开始的时候,就提到说 JavaScript 是一个本身不太严谨的语言,甚至有很多缺陷,但是这不妨碍人们围绕着 JavaScript 创建出不同的框架和库来克服这些问题。我觉得它的这种特质就很像我们自己,我们每个人都不是完美的,我们周围的环境也充满变化,但是这反而造就了我们的多样性,形成了一个多元的世界。
|
||||||
|
|
||||||
|
在今年教师节的时候,我收到了极客时间寄来的一本好书,叫做《把自己作为方法》。这本书是与社会学家项飙的对话。我最早了解项飙老师,是从他的《跨越边界的社区:北京“浙江村”的生活史》论文和《全球猎身》这本书开始的。之所以会提到他的作品,是因为项飙老师能打动我的地方在于他所提出的每一个观点都产生于个人经验。我们今天的世界太过于中心化,我们可能更愿意相信基于中心意见领袖的人云亦云,或者一些假大空的概念,而忽略了我们自身的体验。
|
||||||
|
|
||||||
|
而对于我而言,从 JavaScript 作为一个锚点,就可以延伸出对世界的理解。因为它是一门我在很长一段时间内每天在用的语言,它是真实的,不需要我从其他人的思想中去抽象理解。而它的很多底层逻辑又是与很多事情相通的。换句话说,我认为我们越是能就身边的不起眼的小事着手,把它吃透、做精、做细,一竿子通到底,反而更容易触类旁通,去了解一些离我们很远的事情。
|
||||||
|
|
||||||
|
那怎么能吃透一件事情呢?就拿 JavaScript 来说,我认为首先需要做的是发现它的核心问题;再者是如何来解决这些问题。这两者的分量各占一半。换句话说,我们如果能清楚地定义问题本身,就等于解决了一半的问题。如果我们还能在这个基础上,想清楚它的解决方案,那么,我们就等于真正地掌握了它。
|
||||||
|
|
||||||
|
JavaScript 面临的不确定性
|
||||||
|
|
||||||
|
首先,我们看到 JavaScript 作为一种语言,从它的设计到实现,所用的时间非常短。这里面缺少非常严谨的思考,也就导致了我们在开发中,在处理各种异常的时候,会遇到很多的不可控因素。
|
||||||
|
|
||||||
|
其次,是它运行的环境也是充满着不确定性。当我们写好一段代码后,并不知道这段代码会在什么样的环境下运行,因为用户所使用的操作系统不同,浏览器的厂商也不同,JS 引擎的版本、屏幕的大小、移动还是桌面,这些都是不同的因素,叠加在一起,可能会产生成千上万种的组合方式。而且用户访问我们 Web 应用的网络、带宽和所在的地区,也都是不可控的。而我们所开发出来的程序,却要满足大多数环境下可以运行的需求,这本身就是一个面对巨大不确定性下的挑战。
|
||||||
|
|
||||||
|
同时,JavaScript 的不确定性还不仅源于环境,另外一种不确定性是来自我们的用户。很多时候,我们不可能控制用户的行为方式,我们不知道用户会先使用我们应用的哪个功能,后使用哪个功能,甚至有些功能是用户从未使用过的。而且,我们也无法完全控制用户的行为轨迹,就拿一个购票的应用来说,有些用户可能是通过搜索找到产品购买的,而有的用户可能是通过筛选条件找到产品购买的,有些用户可能是先加入购物车,而有的用户可能又选择直接购买。这些不同的行为轨迹都需要对应的系统流程来支持并且处理。
|
||||||
|
|
||||||
|
第四点是非功能性的,比如流量上的不可控。我们并不知道用户的数量和访问规模,我们只能通过预估和事后的分析来判断我们的系统访问量。而通常,访问量的不同又会直接影响我们系统的性能,反应在客户端,就是给用户带来的体验以及功能上的影响。
|
||||||
|
|
||||||
|
第五点就是业务需求上的不可控。因为我们的应用程序是直接面客的,这也就导致了我们的程序需要不断且快速地迭代来适应业务的需求。而越是复杂的业务场景,在激烈的竞争中,对变化的要求也就越快。
|
||||||
|
|
||||||
|
JavaScript 问题的解决方案
|
||||||
|
|
||||||
|
所以作为一个前端开发者来说,每天都要和大量的不确定性打交道。但是这并没有妨碍很多非常成熟的、可靠的系统在这种不确定的环境下被开发出来。那这些系统都有着什么样的特点呢?
|
||||||
|
|
||||||
|
第一是我们说的响应式编程思想。响应式编程的特点是要我们不断的感知和反应,比如面对用户行为的变化,我们就需要不断地基于事件来处理。同时,我们知道,越是面对复杂的未知的场景,我们就应该尽可能地确保我们对副作用的控制。我们需要知道在输入相同的情况下,应该如何保证在相同的函数操作下,结果也是相同的。同时,为了保证状态不可变,我们应当如何尽量在复制的数据或对象上进行操作,而不会改变状态本身。
|
||||||
|
|
||||||
|
第二是面向对象的编程思想和设计模式。只有当我们按照业务对象和领域来设计我们的系统和程序的时候,才能更好地应对业务的改变所带来的挑战,所以一个好的软件开发者,肯定同时也是一个很好的业务分析师,因为只有当我们掌握了深层次的业务逻辑后,才能更好地写出对应的程序。否则,我们就很容易陷入重复的开发、返工以及及大量的重构来满足业务变化。
|
||||||
|
|
||||||
|
第三是我们在开发中所运用的算法思想。在我们的程序所消耗的内存量、访问量、处理的数据量和访问的频率不高的情况下,也许算法并不会体现出它的价值。但是当我们的业务量、数据量增长,或者需要渲染的内容增加的时候,就会遇到各种性能瓶颈,这时,就是我们需要用算法思维来解决一些问题的时候了。而且往往越是复杂的问题,越是考验基本功。
|
||||||
|
|
||||||
|
第四是我们在开发的过程中所用到的工具,比如 Flow 就从很大程度上帮助我们解决了 JavaScript 自身的很多短板,让一个很随意的、缺乏严谨的类型系统的语言,也可以使用类型系统来做相关的类型检查。
|
||||||
|
|
||||||
|
第五是我们在 JavaScript 之势中介绍的域驱动的设计理念以及大前端,本质上都是强调业务驱动开发的重要性。这种设计理念,从某种角度来看,就是要求我们在开发中以终为始,不断地思考我们开发的目的。
|
||||||
|
|
||||||
|
为什么说知识可以触类旁通
|
||||||
|
|
||||||
|
我认为在解决 JavaScript 的不确定性的过程中,我所学到的东西,完全可以应用在生活的方方面面。举几个简单的例子,比如我们说响应式的设计,在生活中也是同样的,我们需要面对不确定性的解决方案。在面对未知的时候,我们几乎就可以把先行动、后感知、再反应作为一个模版。
|
||||||
|
|
||||||
|
再比如提到算法,我们就不能不提到动态规划,同样,在面对生活上的问题时,我们很多时候都应该时刻考虑全局的最优解,而不是局部的最优解。否则,我们很多时候可能都会因小失大。因为过分贪心,导致没有在大的问题上真正得到最优的解决方案。
|
||||||
|
|
||||||
|
再比如在开发的过程中,如果我们真的想达到事半功倍的效果,就必须要时刻了解业务的需求,细化深入地理解业务的流程,同时,为了提高开发的效率,我们也要思考如何能够优化开发流水线。这里,就又和软件工程有着密不可分的关系,所以开发到了最后,不仅是简单的开发问题,它延展出的是业务系统分析和项目管理的能力。
|
||||||
|
|
||||||
|
JavaScript 界的大佬道格拉斯·克拉特福德今年提出了一个非常具有争议的概念,就是 JavaScript 已死。虽然这样的观点看上去比较偏激,但是我认为 JavaScript 并不是完全不可以被取代的。不过,即使有一天,JavaScript 成为了历史,它在编程语言历史上的地位也是不会动摇的,它的很多思想也还会延续到任何一个要取代它的语言中。
|
||||||
|
|
||||||
|
其实,有很长一段时间,我很羡慕后端开发,他们就像“三好学生”一样。而且我认为更适合用来作为第一门计算机语言来学习的还是像 C++ 或 Java 这样的语言,因为它们有更加严谨的语法,一个好的基本功和扎实的基础是解决任何复杂问题的关键。
|
||||||
|
|
||||||
|
JavaScript 虽然很好用,但也正是因为它太容易上手了,所以导致了很多人没有能把它用好。而且在前端开发中,需要考虑的种种问题真是非常多。但是随着时间的推移,我渐渐接受了 JavaScript,因为正是它的种种问题和带来的不确定性,让我们在开发中锻炼了深度思考的能力,并且在克服这些问题的同时,也让我们自身变成了更有韧性的人。同时,相比后端的“一刀切”,前端对很多问题的“容忍”也造就了开发环境的包容。正是在不断的变化和不可控中,我们不断调整,优化系统稳定性,这个过程我们更加会注意如何减少副作用,提高在混乱中建立秩序的能力。
|
||||||
|
|
||||||
|
正好,这个结束语也写在了辞旧迎新的新年之际,在此,我也希望此时此刻读到这篇文章的你,也能够在新的一年,通过 JavaScript 这门语言更好地拥抱不确定性。
|
||||||
|
|
||||||
|
最后,我还给你准备了一份毕业问卷,希望你能花两三分钟填写一下,非常期待能听到你对这门课的反馈。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
89
专栏/Java并发编程实战/00学习攻略如何才能学好并发编程?.md
Normal file
89
专栏/Java并发编程实战/00学习攻略如何才能学好并发编程?.md
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
00 学习攻略 如何才能学好并发编程?
|
||||||
|
并发编程并不是一门相对独立的学科,而是一个综合学科。并发编程相关的概念和技术看上非常零散,相关度也很低,总给你一种这样的感觉:我已经学习很多相关技术了,可还是搞不定并发编程。那如何才能学习好并发编程呢?
|
||||||
|
|
||||||
|
其实很简单,只要你能从两个方面突破一下就可以了。一个是“跳出来,看全景”,另一个是“钻进去,看本质”。
|
||||||
|
|
||||||
|
跳出来,看全景
|
||||||
|
|
||||||
|
我们先说“跳出来”。你应该也知道,学习最忌讳的就是“盲人摸象”,只看到局部,而没有看到全局。所以,你需要从一个个单一的知识和技术中“跳出来”,高屋建瓴地看并发编程。当然,这首要之事就是你建立起一张全景图。
|
||||||
|
|
||||||
|
不过,并发编程相关的知识和技术还真是错综复杂,时至今日也还没有一张普遍认可的全景图,也许这正是很多人在并发编程方面难以突破的原因吧。好在经过多年摸爬滚打,我自己已经“勾勒”出了一张全景图,不一定科学,但是在某种程度上我想它还是可以指导你学好并发编程的。
|
||||||
|
|
||||||
|
在我看来,并发编程领域可以抽象成三个核心问题:分工、同步和互斥。
|
||||||
|
|
||||||
|
1. 分工
|
||||||
|
|
||||||
|
所谓分工,类似于现实中一个组织完成一个项目,项目经理要拆分任务,安排合适的成员去完成。
|
||||||
|
|
||||||
|
在并发编程领域,你就是项目经理,线程就是项目组成员。任务分解和分工对于项目成败非常关键,不过在并发领域里,分工更重要,它直接决定了并发程序的性能。在现实世界里,分工是很复杂的,著名数学家华罗庚曾用“烧水泡茶”的例子通俗地讲解了统筹方法(一种安排工作进程的数学方法),“烧水泡茶”这么简单的事情都这么多说道,更何况是并发编程里的工程问题呢。
|
||||||
|
|
||||||
|
既然分工很重要又很复杂,那一定有前辈努力尝试解决过,并且也一定有成果。的确,在并发编程领域这方面的成果还是很丰硕的。Java SDK并发包里的Executor、Fork/Join、Future本质上都是一种分工方法。除此之外,并发编程领域还总结了一些设计模式,基本上都是和分工方法相关的,例如生产者-消费者、Thread-Per-Message、Worker Thread模式等都是用来指导你如何分工的。
|
||||||
|
|
||||||
|
学习这部分内容,最佳的方式就是和现实世界做对比。例如生产者-消费者模式,可以类比一下餐馆里的大厨和服务员,大厨就是生产者,负责做菜,做完放到出菜口,而服务员就是消费者,把做好的菜给你端过来。不过,我们经常会发现,出菜口有时候一下子出了好几个菜,服务员是可以把这一批菜同时端给你的。其实这就是生产者-消费者模式的一个优点,生产者一个一个地生产数据,而消费者可以批处理,这样就提高了性能。
|
||||||
|
|
||||||
|
2. 同步
|
||||||
|
|
||||||
|
分好工之后,就是具体执行了。在项目执行过程中,任务之间是有依赖的,一个任务结束后,依赖它的后续任务就可以开工了,后续工作怎么知道可以开工了呢?这个就是靠沟通协作了,这是一项很重要的工作。
|
||||||
|
|
||||||
|
在并发编程领域里的同步,主要指的就是线程间的协作,本质上和现实生活中的协作没区别,不过是一个线程执行完了一个任务,如何通知执行后续任务的线程开工而已。
|
||||||
|
|
||||||
|
协作一般是和分工相关的。Java SDK并发包里的Executor、Fork/Join、Future本质上都是分工方法,但同时也能解决线程协作的问题。例如,用Future可以发起一个异步调用,当主线程通过get()方法取结果时,主线程就会等待,当异步执行的结果返回时,get()方法就自动返回了。主线程和异步线程之间的协作,Future工具类已经帮我们解决了。除此之外,Java SDK里提供的CountDownLatch、CyclicBarrier、Phaser、Exchanger也都是用来解决线程协作问题的。
|
||||||
|
|
||||||
|
不过还有很多场景,是需要你自己来处理线程之间的协作的。
|
||||||
|
|
||||||
|
工作中遇到的线程协作问题,基本上都可以描述为这样的一个问题:当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行。例如,在生产者-消费者模型里,也有类似的描述,“当队列满时,生产者线程等待,当队列不满时,生产者线程需要被唤醒执行;当队列空时,消费者线程等待,当队列不空时,消费者线程需要被唤醒执行。”
|
||||||
|
|
||||||
|
在Java并发编程领域,解决协作问题的核心技术是管程,上面提到的所有线程协作技术底层都是利用管程解决的。管程是一种解决并发问题的通用模型,除了能解决线程协作问题,还能解决下面我们将要介绍的互斥问题。可以这么说,管程是解决并发问题的万能钥匙。
|
||||||
|
|
||||||
|
所以说,这部分内容的学习,关键是理解管程模型,学好它就可以解决所有问题。其次是了解Java SDK并发包提供的几个线程协作的工具类的应用场景,用好它们可以妥妥地提高你的工作效率。
|
||||||
|
|
||||||
|
3. 互斥
|
||||||
|
|
||||||
|
分工、同步主要强调的是性能,但并发程序里还有一部分是关于正确性的,用专业术语叫“线程安全”。并发程序里,当多个线程同时访问同一个共享变量的时候,结果是不确定的。不确定,则意味着可能正确,也可能错误,事先是不知道的。而导致不确定的主要源头是可见性问题、有序性问题和原子性问题,为了解决这三个问题,Java语言引入了内存模型,内存模型提供了一系列的规则,利用这些规则,我们可以避免可见性问题、有序性问题,但是还不足以完全解决线程安全问题。解决线程安全问题的核心方案还是互斥。
|
||||||
|
|
||||||
|
所谓互斥,指的是同一时刻,只允许一个线程访问共享变量。
|
||||||
|
|
||||||
|
实现互斥的核心技术就是锁,Java语言里synchronized、SDK里的各种Lock都能解决互斥问题。虽说锁解决了安全性问题,但同时也带来了性能问题,那如何保证安全性的同时又尽量提高性能呢?可以分场景优化,Java SDK里提供的ReadWriteLock、StampedLock就可以优化读多写少场景下锁的性能。还可以使用无锁的数据结构,例如Java SDK里提供的原子类都是基于无锁技术实现的。
|
||||||
|
|
||||||
|
除此之外,还有一些其他的方案,原理是不共享变量或者变量只允许读。这方面,Java提供了Thread Local和final关键字,还有一种Copy-on-write的模式。
|
||||||
|
|
||||||
|
使用锁除了要注意性能问题外,还需要注意死锁问题。
|
||||||
|
|
||||||
|
这部分内容比较复杂,往往还是跨领域的,例如要理解可见性,就需要了解一些CPU和缓存的知识;要理解原子性,就需要理解一些操作系统的知识;很多无锁算法的实现往往也需要理解CPU缓存。这部分内容的学习,需要博览群书,在大脑里建立起CPU、内存、I/O执行的模拟器。这样遇到问题就能得心应手了。
|
||||||
|
|
||||||
|
跳出来,看全景,可以让你的知识成体系,所学知识也融汇贯通起来,由点成线,由线及面,画出自己的知识全景图。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
并发编程全景图之思维导图
|
||||||
|
|
||||||
|
钻进去,看本质
|
||||||
|
|
||||||
|
但是光跳出来还不够,还需要下一步,就是在某个问题上钻进去,深入理解,找到本质。
|
||||||
|
|
||||||
|
就拿我个人来说,我已经烦透了去讲述或被讲述一堆概念和结论,而不分析这些概念和结论是怎么来的,以及它们是用来解决什么问题的。在大学里,这样的教材很流行,直接导致了芸芸学子成绩很高,但解决问题的能力很差。其实,知其然知其所以然,才算真的学明白了。
|
||||||
|
|
||||||
|
我属于理论派,我认为工程上的解决方案,一定要有理论做基础。所以在学习并发编程的过程中,我都会探索它背后的理论是什么。比如,当看到Java SDK里面的条件变量Condition的时候,我会下意识地问,“它是从哪儿来的?是Java的特有概念,还是一个通用的编程概念?”当我知道它来自管程的时候,我又会问,“管程被提出的背景和解决的问题是什么?”这样一路探索下来,我发现Java语言里的并发技术基本都是有理论基础的,并且这些理论在其他编程语言里也有类似的实现。所以我认为,技术的本质是背后的理论模型。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
当初我学习Java并发编程的时候,试图上来就看Java SDK的并发包,但是很快就放弃了。原因是我觉得东西太多,眼花缭乱的,虽然借助网络上的技术文章,感觉都看懂了,但是很快就又忘了。实际应用的时候大脑也一片空白,根本不知道从哪里下手,有时候好不容易解决了个问题,也不知道这个方案是不是合适的。
|
||||||
|
|
||||||
|
我知道根本原因是,我的并发知识还没有成体系。
|
||||||
|
|
||||||
|
我想,要让自己的知识成体系,一定要挖掘Java SDK并发包背后的设计理念。Java SDK并发包是并发大师Doug Lea设计的,他一定不是随意设计的,一定是深思熟虑的,其背后是Doug Lea对并发问题的深刻认识。可惜这个设计的思想目前并没有相关的论文,所以只能自己琢磨了。
|
||||||
|
|
||||||
|
分工、同步和互斥的全景图,是我对并发问题的个人总结,不一定正确,但是可以帮助我快速建立解决并发问题的思路,梳理并发编程的知识,加深认识。我将其分享给你,希望对你也有用。
|
||||||
|
|
||||||
|
对于某个具体的技术,我建议你探索它背后的理论本质,理论的应用面更宽,一项优秀的理论往往在多个语言中都有体现,在多个不同领域都有应用。所以探求理论本质,既能加深对技术本身的理解,也能拓展知识深度和广度,这是个一举多得的方法。这方面,希望我们一起探讨,共同进步。
|
||||||
|
|
||||||
|
欢迎在留言区跟我分享你的经历与想法。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
63
专栏/Java并发编程实战/00开篇词你为什么需要学习并发编程?.md
Normal file
63
专栏/Java并发编程实战/00开篇词你为什么需要学习并发编程?.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
00 开篇词 你为什么需要学习并发编程?
|
||||||
|
你好,我是王宝令,资深架构师,目前从事电商架构的设计工作。从毕业到现在,我前前后后写了15年的程序,刚毕业的时候从事证券业务的开发,开发语言是C/C++,之后从事ERP产品的研发,开发语言主要是C#和Java,最近几年主要是从事Java开发平台和基础中间件的设计开发工作。
|
||||||
|
|
||||||
|
还记得毕业后我接触的第一个项目是证券相关的,国外的同事用C语言写了一个内存数据库,代码写得极为简练优美,我当时怀着无比崇敬的心情把代码看了又看,看完感觉受益匪浅。不过兴奋之余,我也有些焦虑,因为其中一块并发相关的代码,我看得是云里雾里,总感觉自己没有悟透。
|
||||||
|
|
||||||
|
我下意识地告诉自己说这块的知识积累还不够,所以要勤学苦练。你可知道,15年前相关的学习资料并不多,我的师傅向我推荐了《操作系统原理》这本教材,他说:“并发编程最早的应用领域就是操作系统的实现,你把这本书看懂了,并发的问题自然就解决了。”但是理论和实践之间总是有鸿沟的,之后好多年,最让我感到无助的还是处理并发相关的问题。
|
||||||
|
|
||||||
|
并发编程的掌握过程并不容易。我相信为了解决这个问题,你也听别人总结过并发编程的第一原则,那就是不要写并发程序。这个原则在我刚毕业的那几年曾经是行得通的,那个时候多核服务器还是一种奢侈品,系统的并发量也很低,借助数据库和类似Tomcat这种中间件,我们基本上不用写并发程序。或者说,并发问题基本上都被中间件和数据库解决了。
|
||||||
|
|
||||||
|
但是最近几年,并发编程已经慢慢成为一项必备技能。
|
||||||
|
|
||||||
|
这主要是硬件的驱动以及国内互联网行业的飞速发展决定的,现在64核的服务器已经飞入寻常百姓家,大型互联网厂商的系统并发量轻松过百万,传统的中间件和数据库已经不能为我们遮风挡雨,反而成了瓶颈所在。
|
||||||
|
|
||||||
|
于是,并发编程最近几年成为非常热门的领域,人才稀缺。但与此同时,关于并发编程的书籍也渐渐丰富起来了。所以当极客时间团队和我聊这个专栏的时候,我的第一个疑问就是目前市面上已经有很多这方面的图书了,而且很多都非常优秀,是否还有必要搞一个这样的专栏。
|
||||||
|
|
||||||
|
但是深入想过之后,我坚定了写作的信心。这些年接触的大部分同学,都是工作几年后很多技术突飞猛进,却只有并发编程成为瓶颈,虽然并发相关的类库他们也熟悉,却总是写不出正确、高效的并发程序,原因在哪里?我发现很多人是因为某个地方有了盲点,忽略了一些细节,但恰恰是这些细节决定了程序的正确性和效率。
|
||||||
|
|
||||||
|
而这个盲点有时候涉及对操作系统的理解,有时候又涉及一点硬件知识,非常复杂,如果要推荐相关图书,可能要推荐好几本,这就有点“大炮打蚊子”的感觉了,效率很差。同时图书更追求严谨性,却也因此失掉了形象性,所以阅读的过程也确实有点艰辛。
|
||||||
|
|
||||||
|
我想,如果能够把这些问题解决,那么做这个事情应该是有意义的。
|
||||||
|
|
||||||
|
例如,Java里synchronized、wait()/notify()相关的知识很琐碎,看懂难,会用更难。但实际上synchronized、wait()、notify()不过是操作系统领域里管程模型的一种实现而已,Java SDK并发包里的条件变量Condition也是管程里的概念,synchronized、wait()/notify()、条件变量这些知识如果单独理解,自然是管中窥豹。但是如果站在管程这个理论模型的高度,你就会发现这些知识原来这么简单,同时用起来也就得心应手了。
|
||||||
|
|
||||||
|
管程作为一种解决并发问题的模型,是继信号量模型之后的一项重大创新,它与信号量在逻辑上是等价的(可以用管程实现信号量,也可以用信号量实现管程),但是相比之下管程更易用。而且,很多编程语言都支持管程,搞懂管程,对学习其他很多语言的并发编程有很大帮助。然而,很多人急于学习Java并发编程技术,却忽略了技术背后的理论和模型,而理论和模型却往往比具体的技术更为重要。
|
||||||
|
|
||||||
|
此外,Java经过这些年的发展,Java SDK并发包提供了非常丰富的功能,对于初学者来说可谓是眼花缭乱,好多人觉得无从下手。但是,Java SDK并发包乃是并发大师Doug Lea出品,堪称经典,它内部一定是有章可循的。那它的章法在哪里呢?
|
||||||
|
|
||||||
|
其实并发编程可以总结为三个核心问题:分工、同步、互斥。
|
||||||
|
|
||||||
|
所谓分工指的是如何高效地拆解任务并分配给线程,而同步指的是线程之间如何协作,互斥则是保证同一时刻只允许一个线程访问共享资源。Java SDK并发包很大部分内容都是按照这三个维度组织的,例如Fork/Join框架就是一种分工模式,CountDownLatch就是一种典型的同步方式,而可重入锁则是一种互斥手段。
|
||||||
|
|
||||||
|
当把并发编程核心的问题搞清楚,再回过头来看Java SDK并发包,你会感觉豁然开朗,它不过是针对并发问题开发出来的工具而已,此时的SDK并发包可以任你“盘”了。
|
||||||
|
|
||||||
|
而且,这三个核心问题是跨语言的,你如果要学习其他语言的并发编程类库,完全可以顺着这三个问题按图索骥。Java SDK并发包其余的一部分则是并发容器和原子类,这些比较容易理解,属于辅助工具,其他语言里基本都能找到对应的。
|
||||||
|
|
||||||
|
所以,你说并发编程难学吗?
|
||||||
|
|
||||||
|
首先,难是肯定的。因为这其中涉及操作系统、CPU、内存等等多方面的知识,如果你缺少某一块,那理解起来自然困难。其次,难不难学也可能因人而异,就我的经验来看,很多人在学习并发编程的时候,总是喜欢从点出发,希望能从点里找到规律或者本质,最后却把自己绕晕了。
|
||||||
|
|
||||||
|
我前面说过,并发编程并不是Java特有的语言特性,它是一个通用且早已成熟的领域。Java只是根据自身情况做了实现罢了,当你理解或学习并发编程的时候,如果能够站在较高层面,系统且有体系地思考问题,那就会容易很多。
|
||||||
|
|
||||||
|
所以,我希望这个专栏更多地谈及问题背后的本质、问题的起源,同时站在理论、模型的角度讲解Java并发,让你的知识更成体系,融会贯通。最终让你能够得心应手地解决各种并发难题,同时将这些知识用于其他编程语言,让你的一分辛劳三分收获。
|
||||||
|
|
||||||
|
下面就是这个专栏的目录,你可以快速了解下整个专栏的知识结构体系。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
当然,我们要坚持下去,不能三天打鱼两天晒网,因为滴水穿石非一日之功。
|
||||||
|
|
||||||
|
很多人都说学习是反人性的,开始容易,但是长久的坚持却很难。这个我也认同,我面试的时候,就经常问候选人一个问题:“工作中,有没有一件事你自己坚持了很久,并且从中获益?”如果候选人能够回答出来,那会是整个面试的加分项,因为我觉得,坚持真是一个可贵的品质,一件事情,有的人三分热度,而有的人,一做就能做一年,或者更久。你放长到时间的维度里看,这两种人,最后的成就绝对是指数级的差距。
|
||||||
|
|
||||||
|
我希望你能和我坚持下来,我们一起学习,一起交流,遇到问题不是简单地抱怨和逃避,而是努力探寻答案与解决方法。这一次,就让我们一起来坚持探索并发编程的奥秘,体会探索知识的乐趣。今天的文章是开篇词,我们的主菜很快就来,如果可以的话,还请在留言区中做个自我介绍,和我聊聊你目前的工作、学习情况,以及你在并发编程方面的学习痛点,方便我在后面针对性地给你讲解,这样,我们可以彼此了解。
|
||||||
|
|
||||||
|
最后,感谢你对我的信任,我定会努力实现完美交付。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
179
专栏/Java并发编程实战/01可见性、原子性和有序性问题:并发编程Bug的源头.md
Normal file
179
专栏/Java并发编程实战/01可见性、原子性和有序性问题:并发编程Bug的源头.md
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
01 可见性、原子性和有序性问题:并发编程Bug的源头
|
||||||
|
如果你细心观察的话,你会发现,不管是哪一门编程语言,并发类的知识都是在高级篇里。换句话说,这块知识点其实对于程序员来说,是比较进阶的知识。我自己这么多年学习过来,也确实觉得并发是比较难的,因为它会涉及到很多的底层知识,比如若你对操作系统相关的知识一无所知的话,那去理解一些原理就会费些力气。这是我们整个专栏的第一篇文章,我说这些话的意思是如果你在中间遇到自己没想通的问题,可以去查阅资料,也可以在评论区找我,以保证你能够跟上学习进度。
|
||||||
|
|
||||||
|
你我都知道,编写正确的并发程序是一件极困难的事情,并发程序的Bug往往会诡异地出现,然后又诡异地消失,很难重现,也很难追踪,很多时候都让人很抓狂。但要快速而又精准地解决“并发”类的疑难杂症,你就要理解这件事情的本质,追本溯源,深入分析这些Bug的源头在哪里。
|
||||||
|
|
||||||
|
那为什么并发编程容易出问题呢?它是怎么出问题的?今天我们就重点聊聊这些Bug的源头。
|
||||||
|
|
||||||
|
并发程序幕后的故事
|
||||||
|
|
||||||
|
这些年,我们的CPU、内存、I/O设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。CPU和内存的速度差异可以形象地描述为:CPU是天上一天,内存是地上一年(假设CPU执行一条普通指令需要一天,那么CPU读写内存得等待一年的时间)。内存和I/O设备的速度差异就更大了,内存是天上一天,I/O设备是地上十年。
|
||||||
|
|
||||||
|
程序里大部分语句都要访问内存,有些还要访问I/O,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板),程序整体的性能取决于最慢的操作——读写I/O设备,也就是说单方面提高CPU性能是无效的。
|
||||||
|
|
||||||
|
为了合理利用CPU的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
|
||||||
|
|
||||||
|
|
||||||
|
CPU增加了缓存,以均衡与内存的速度差异;
|
||||||
|
操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;
|
||||||
|
编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
|
||||||
|
|
||||||
|
|
||||||
|
现在我们几乎所有的程序都默默地享受着这些成果,但是天下没有免费的午餐,并发程序很多诡异问题的根源也在这里。
|
||||||
|
|
||||||
|
源头之一:缓存导致的可见性问题
|
||||||
|
|
||||||
|
在单核时代,所有的线程都是在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。例如在下面的图中,线程A和线程B都是操作同一个CPU里面的缓存,所以线程A更新了变量V的值,那么线程B之后再访问变量V,得到的一定是V的最新值(线程A写过的值)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
CPU缓存与内存的关系图
|
||||||
|
|
||||||
|
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
|
||||||
|
|
||||||
|
多核时代,每颗CPU都有自己的缓存,这时CPU缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。比如下图中,线程A操作的是CPU-1上的缓存,而线程B操作的是CPU-2上的缓存,很明显,这个时候线程A对变量V的操作对于线程B而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
多核CPU的缓存与内存关系图
|
||||||
|
|
||||||
|
下面我们再用一段代码来验证一下多核场景下的可见性问题。下面的代码,每执行一次add10K()方法,都会循环10000次count+=1操作。在calc()方法中我们创建了两个线程,每个线程调用一次add10K()方法,我们来想一想执行calc()方法得到的结果应该是多少呢?
|
||||||
|
|
||||||
|
public class Test {
|
||||||
|
private long count = 0;
|
||||||
|
private void add10K() {
|
||||||
|
int idx = 0;
|
||||||
|
while(idx++ < 10000) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static long calc() {
|
||||||
|
final Test test = new Test();
|
||||||
|
// 创建两个线程,执行add()操作
|
||||||
|
Thread th1 = new Thread(()->{
|
||||||
|
test.add10K();
|
||||||
|
});
|
||||||
|
Thread th2 = new Thread(()->{
|
||||||
|
test.add10K();
|
||||||
|
});
|
||||||
|
// 启动两个线程
|
||||||
|
th1.start();
|
||||||
|
th2.start();
|
||||||
|
// 等待两个线程执行结束
|
||||||
|
th1.join();
|
||||||
|
th2.join();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
直觉告诉我们应该是20000,因为在单线程里调用两次add10K()方法,count的值就是20000,但实际上calc()的执行结果是个10000到20000之间的随机数。为什么呢?
|
||||||
|
|
||||||
|
我们假设线程A和线程B同时开始执行,那么第一次都会将 count=0 读到各自的CPU缓存里,执行完 count+=1 之后,各自CPU缓存里的值都是1,同时写入内存后,我们会发现内存中是1,而不是我们期望的2。之后由于各自的CPU缓存里都有了count的值,两个线程都是基于CPU缓存里的 count 值来计算,所以导致最终count的值都是小于20000的。这就是缓存的可见性问题。
|
||||||
|
|
||||||
|
循环10000次count+=1操作如果改为循环1亿次,你会发现效果更明显,最终count的值接近1亿,而不是2亿。如果循环10000次,count的值接近20000,原因是两个线程不是同时启动的,有一个时差。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
变量count在CPU缓存和内存的分布图
|
||||||
|
|
||||||
|
源头之二:线程切换带来的原子性问题
|
||||||
|
|
||||||
|
由于IO太慢,早期的操作系统就发明了多进程,即便在单核的CPU上我们也可以一边听着歌,一边写Bug,这个就是多进程的功劳。
|
||||||
|
|
||||||
|
操作系统允许某个进程执行一小段时间,例如50毫秒,过了50毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个50毫秒称为“时间片”。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
线程切换示意图
|
||||||
|
|
||||||
|
在一个时间片内,如果一个进程进行一个IO操作,例如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让CPU的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得CPU的使用权了。
|
||||||
|
|
||||||
|
这里的进程在等待IO时之所以会释放CPU使用权,是为了让CPU在这段等待时间里可以做别的事情,这样一来CPU的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样IO的使用率也上来了。
|
||||||
|
|
||||||
|
是不是很简单的逻辑?但是,虽然看似简单,支持多进程分时复用在操作系统的发展史上却具有里程碑意义,Unix就是因为解决了这个问题而名噪天下的。
|
||||||
|
|
||||||
|
早期的操作系统基于进程来调度CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。
|
||||||
|
|
||||||
|
Java并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异Bug的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成,例如上面代码中的count += 1,至少需要三条CPU指令。
|
||||||
|
|
||||||
|
|
||||||
|
指令1:首先,需要把变量count从内存加载到CPU的寄存器;
|
||||||
|
指令2:之后,在寄存器中执行+1操作;
|
||||||
|
指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。
|
||||||
|
|
||||||
|
|
||||||
|
操作系统做任务切换,可以发生在任何一条CPU指令执行完,是的,是CPU指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设count=0,如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图的序列执行,那么我们会发现两个线程都执行了count+=1的操作,但是得到的结果不是我们期望的2,而是1。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
非原子操作的执行路径示意图
|
||||||
|
|
||||||
|
我们潜意识里面觉得count+=1这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在count+=1之前,也可以发生在count+=1之后,但就是不会发生在中间。我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。CPU能保证的原子操作是CPU指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。
|
||||||
|
|
||||||
|
源头之三:编译优化带来的有序性问题
|
||||||
|
|
||||||
|
那并发编程里还有没有其他有违直觉容易导致诡异Bug的技术呢?有的,就是有序性。顾名思义,有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。
|
||||||
|
|
||||||
|
在Java领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例getInstance()的方法中,我们首先判断instance是否为空,如果为空,则锁定Singleton.class并再次检查instance是否为空,如果还为空则创建Singleton的一个实例。
|
||||||
|
|
||||||
|
public class Singleton {
|
||||||
|
static Singleton instance;
|
||||||
|
static Singleton getInstance(){
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized(Singleton.class) {
|
||||||
|
if (instance == null)
|
||||||
|
instance = new Singleton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
假设有两个线程A、B同时调用getInstance()方法,他们会同时发现 instance == null ,于是同时对Singleton.class加锁,此时JVM保证只有一个线程能够加锁成功(假设是线程A),另外一个线程则会处于等待状态(假设是线程B);线程A会创建一个Singleton实例,之后释放锁,锁释放后,线程B被唤醒,线程B再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程B检查 instance == null 时会发现,已经创建过Singleton实例了,所以线程B不会再创建一个Singleton实例。
|
||||||
|
|
||||||
|
这看上去一切都很完美,无懈可击,但实际上这个getInstance()方法并不完美。问题出在哪里呢?出在new操作上,我们以为的new操作应该是:
|
||||||
|
|
||||||
|
|
||||||
|
分配一块内存M;
|
||||||
|
在内存M上初始化Singleton对象;
|
||||||
|
然后M的地址赋值给instance变量。
|
||||||
|
|
||||||
|
|
||||||
|
但是实际上优化后的执行路径却是这样的:
|
||||||
|
|
||||||
|
|
||||||
|
分配一块内存M;
|
||||||
|
将M的地址赋值给instance变量;
|
||||||
|
最后在内存M上初始化Singleton对象。
|
||||||
|
|
||||||
|
|
||||||
|
优化后会导致什么问题呢?我们假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现 instance != null ,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
双重检查创建单例的异常执行路径
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
要写好并发程序,首先要知道并发程序的问题在哪里,只有确定了“靶子”,才有可能把问题解决,毕竟所有的解决方案都是针对问题的。并发程序经常出现的诡异问题看上去非常无厘头,但是深究的话,无外乎就是直觉欺骗了我们,只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发Bug都是可以理解、可以诊断的。
|
||||||
|
|
||||||
|
在介绍可见性、原子性、有序性的时候,特意提到缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题,其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。
|
||||||
|
|
||||||
|
我们这个专栏在讲解每项技术的时候,都会尽量将每项技术解决的问题以及产生的问题讲清楚,也希望你能够在这方面多思考、多总结。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
常听人说,在32位的机器上对long型变量进行加减操作存在并发隐患,到底是不是这样呢?现在相信你一定能分析出来。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
208
专栏/Java并发编程实战/02Java内存模型:看Java如何解决可见性和有序性问题.md
Normal file
208
专栏/Java并发编程实战/02Java内存模型:看Java如何解决可见性和有序性问题.md
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
02 Java内存模型:看Java如何解决可见性和有序性问题
|
||||||
|
上一期我们讲到在并发场景中,因可见性、原子性、有序性导致的问题常常会违背我们的直觉,从而成为并发编程的Bug之源。这三者在编程领域属于共性问题,所有的编程语言都会遇到,Java在诞生之初就支持多线程,自然也有针对这三者的技术方案,而且在编程语言领域处于领先地位。理解Java解决并发问题的解决方案,对于理解其他语言的解决方案有触类旁通的效果。
|
||||||
|
|
||||||
|
那我们就先来聊聊如何解决其中的可见性和有序性导致的问题,这也就引出来了今天的主角——Java内存模型。
|
||||||
|
|
||||||
|
Java内存模型这个概念,在职场的很多面试中都会考核到,是一个热门的考点,也是一个人并发水平的具体体现。原因是当并发程序出问题时,需要一行一行地检查代码,这个时候,只有掌握Java内存模型,才能慧眼如炬地发现问题。
|
||||||
|
|
||||||
|
什么是Java内存模型?
|
||||||
|
|
||||||
|
你已经知道,导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。
|
||||||
|
|
||||||
|
合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。
|
||||||
|
|
||||||
|
Java内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则,这也正是本期的重点内容。
|
||||||
|
|
||||||
|
使用volatile的困惑
|
||||||
|
|
||||||
|
volatile关键字并不是Java语言的特产,古老的C语言里也有,它最原始的意义就是禁用CPU缓存。
|
||||||
|
|
||||||
|
例如,我们声明一个volatile变量 volatile int x = 0,它表达的是:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。这个语义看上去相当明确,但是在实际使用的时候却会带来困惑。
|
||||||
|
|
||||||
|
例如下面的示例代码,假设线程A执行writer()方法,按照 volatile 语义,会把变量 “v=true” 写入内存;假设线程B执行reader()方法,同样按照 volatile 语义,线程B会从内存中读取变量v,如果线程B看到 “v == true” 时,那么线程B看到的变量x是多少呢?
|
||||||
|
|
||||||
|
直觉上看,应该是42,那实际应该是多少呢?这个要看Java的版本,如果在低于1.5版本上运行,x可能是42,也有可能是0;如果在1.5以上的版本上运行,x就是等于42。
|
||||||
|
|
||||||
|
// 以下代码来源于【参考1】
|
||||||
|
class VolatileExample {
|
||||||
|
int x = 0;
|
||||||
|
volatile boolean v = false;
|
||||||
|
public void writer() {
|
||||||
|
x = 42;
|
||||||
|
v = true;
|
||||||
|
}
|
||||||
|
public void reader() {
|
||||||
|
if (v == true) {
|
||||||
|
// 这里x会是多少呢?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
分析一下,为什么1.5以前的版本会出现x = 0的情况呢?我相信你一定想到了,变量x可能被CPU缓存而导致可见性问题。这个问题在1.5版本已经被圆满解决了。Java内存模型在1.5版本对volatile语义进行了增强。怎么增强的呢?答案是一项 Happens-Before 规则。
|
||||||
|
|
||||||
|
Happens-Before 规则
|
||||||
|
|
||||||
|
如何理解 Happens-Before 呢?如果望文生义(很多网文也都爱按字面意思翻译成“先行发生”),那就南辕北辙了,Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。就像有心灵感应的两个人,虽然远隔千里,一个人心之所想,另一个人都看得到。Happens-Before 规则就是要保证线程之间的这种“心灵感应”。所以比较正式的说法是:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。
|
||||||
|
|
||||||
|
Happens-Before 规则应该是Java内存模型里面最晦涩的内容了,和程序员相关的规则一共有如下六项,都是关于可见性的。
|
||||||
|
|
||||||
|
恰好前面示例代码涉及到这六项规则中的前三项,为便于你理解,我也会分析上面的示例代码,来看看规则1、2和3到底该如何理解。至于其他三项,我也会结合其他例子作以说明。
|
||||||
|
|
||||||
|
1. 程序的顺序性规则
|
||||||
|
|
||||||
|
这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这还是比较容易理解的,比如刚才那段示例代码,按照程序的顺序,第6行代码 “x = 42;” Happens-Before 于第7行代码 “v = true;”,这就是规则1的内容,也比较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的。
|
||||||
|
|
||||||
|
(为方便你查看,我将那段示例代码在这儿再呈现一遍)
|
||||||
|
|
||||||
|
// 以下代码来源于【参考1】
|
||||||
|
class VolatileExample {
|
||||||
|
int x = 0;
|
||||||
|
volatile boolean v = false;
|
||||||
|
public void writer() {
|
||||||
|
x = 42;
|
||||||
|
v = true;
|
||||||
|
}
|
||||||
|
public void reader() {
|
||||||
|
if (v == true) {
|
||||||
|
// 这里x会是多少呢?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
2. volatile变量规则
|
||||||
|
|
||||||
|
这条规则是指对一个volatile变量的写操作, Happens-Before 于后续对这个volatile变量的读操作。
|
||||||
|
|
||||||
|
这个就有点费解了,对一个volatile变量的写操作相对于后续对这个volatile变量的读操作可见,这怎么看都是禁用缓存的意思啊,貌似和1.5版本以前的语义没有变化啊?如果单看这个规则,的确是这样,但是如果我们关联一下规则3,就有点不一样的感觉了。
|
||||||
|
|
||||||
|
3. 传递性
|
||||||
|
|
||||||
|
这条规则是指如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。
|
||||||
|
|
||||||
|
我们将规则3的传递性应用到我们的例子中,会发生什么呢?可以看下面这幅图:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
示例代码中的传递性规则
|
||||||
|
|
||||||
|
从图中,我们可以看到:
|
||||||
|
|
||||||
|
|
||||||
|
“x=42” Happens-Before 写变量 “v=true” ,这是规则1的内容;
|
||||||
|
写变量“v=true” Happens-Before 读变量 “v=true”,这是规则2的内容 。
|
||||||
|
|
||||||
|
|
||||||
|
再根据这个传递性规则,我们得到结果:“x=42” Happens-Before 读变量“v=true”。这意味着什么呢?
|
||||||
|
|
||||||
|
如果线程B读到了“v=true”,那么线程A设置的“x=42”对线程B是可见的。也就是说,线程B能看到 “x == 42” ,有没有一种恍然大悟的感觉?这就是1.5版本对volatile语义的增强,这个增强意义重大,1.5版本的并发工具包(java.util.concurrent)就是靠volatile语义来搞定可见性的,这个在后面的内容中会详细介绍。
|
||||||
|
|
||||||
|
4. 管程中锁的规则
|
||||||
|
|
||||||
|
这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
|
||||||
|
|
||||||
|
要理解这个规则,就首先要了解“管程指的是什么”。管程是一种通用的同步原语,在Java中指的就是synchronized,synchronized是Java里对管程的实现。
|
||||||
|
|
||||||
|
管程中的锁在Java里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。
|
||||||
|
|
||||||
|
synchronized (this) { //此处自动加锁
|
||||||
|
// x是共享变量,初始值=10
|
||||||
|
if (this.x < 12) {
|
||||||
|
this.x = 12;
|
||||||
|
}
|
||||||
|
} //此处自动解锁
|
||||||
|
|
||||||
|
|
||||||
|
所以结合规则4——管程中锁的规则,可以这样理解:假设x的初始值是10,线程A执行完代码块后x的值会变成12(执行完自动释放锁),线程B进入代码块时,能够看到线程A对x的写操作,也就是线程B能够看到x==12。这个也是符合我们直觉的,应该不难理解。
|
||||||
|
|
||||||
|
5. 线程 start() 规则
|
||||||
|
|
||||||
|
这条是关于线程启动的。它是指主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。
|
||||||
|
|
||||||
|
换句话说就是,如果线程A调用线程B的 start() 方法(即在线程A中启动线程B),那么该start()操作 Happens-Before 于线程B中的任意操作。具体可参考下面示例代码。
|
||||||
|
|
||||||
|
Thread B = new Thread(()->{
|
||||||
|
// 主线程调用B.start()之前
|
||||||
|
// 所有对共享变量的修改,此处皆可见
|
||||||
|
// 此例中,var==77
|
||||||
|
});
|
||||||
|
// 此处对共享变量var修改
|
||||||
|
var = 77;
|
||||||
|
// 主线程启动子线程
|
||||||
|
B.start();
|
||||||
|
|
||||||
|
|
||||||
|
6. 线程 join() 规则
|
||||||
|
|
||||||
|
这条是关于线程等待的。它是指主线程A等待子线程B完成(主线程A通过调用子线程B的join()方法实现),当子线程B完成后(主线程A中join()方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
|
||||||
|
|
||||||
|
换句话说就是,如果在线程A中,调用线程B的 join() 并成功返回,那么线程B中的任意操作Happens-Before 于该 join() 操作的返回。具体可参考下面示例代码。
|
||||||
|
|
||||||
|
Thread B = new Thread(()->{
|
||||||
|
// 此处对共享变量var修改
|
||||||
|
var = 66;
|
||||||
|
});
|
||||||
|
// 例如此处对共享变量修改,
|
||||||
|
// 则这个修改结果对线程B可见
|
||||||
|
// 主线程启动子线程
|
||||||
|
B.start();
|
||||||
|
B.join()
|
||||||
|
// 子线程所有对共享变量的修改
|
||||||
|
// 在主线程调用B.join()之后皆可见
|
||||||
|
// 此例中,var==66
|
||||||
|
|
||||||
|
|
||||||
|
被我们忽视的final
|
||||||
|
|
||||||
|
前面我们讲volatile为的是禁用缓存以及编译优化,我们再从另外一个方面来看,有没有办法告诉编译器优化得更好一点呢?这个可以有,就是final关键字。
|
||||||
|
|
||||||
|
final修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。Java编译器在1.5以前的版本的确优化得很努力,以至于都优化错了。
|
||||||
|
|
||||||
|
问题类似于上一期提到的利用双重检查方法创建单例,构造函数的错误重排导致线程可能看到final变量的值会变化。详细的案例可以参考这个文档。
|
||||||
|
|
||||||
|
当然了,在1.5以后Java内存模型对final类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。
|
||||||
|
|
||||||
|
“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将this赋值给了全局变量global.obj,这就是“逸出”,线程通过global.obj读取x是有可能读到0的。因此我们一定要避免“逸出”。
|
||||||
|
|
||||||
|
// 以下代码来源于【参考1】
|
||||||
|
final int x;
|
||||||
|
// 错误的构造函数
|
||||||
|
public FinalFieldExample() {
|
||||||
|
x = 3;
|
||||||
|
y = 4;
|
||||||
|
// 此处就是讲this逸出,
|
||||||
|
global.obj = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Java的内存模型是并发编程领域的一次重要创新,之后C++、C#、Golang等高级语言都开始支持内存模型。Java内存模型里面,最晦涩的部分就是Happens-Before规则了,Happens-Before规则最初是在一篇叫做Time, Clocks, and the Ordering of Events in a Distributed System的论文中提出来的,在这篇论文中,Happens-Before的语义是一种因果关系。在现实世界里,如果A事件是导致B事件的起因,那么A事件一定是先于(Happens-Before)B事件发生的,这个就是Happens-Before语义的现实理解。
|
||||||
|
|
||||||
|
在Java语言里面,Happens-Before的语义本质上是一种可见性,A Happens-Before B 意味着A事件对B事件来说是可见的,无论A事件和B事件是否发生在同一个线程里。例如A事件发生在线程1上,B事件发生在线程2上,Happens-Before规则保证线程2上也能看到A事件的发生。
|
||||||
|
|
||||||
|
Java内存模型主要分为两部分,一部分面向你我这种编写并发程序的应用开发人员,另一部分是面向JVM的实现人员的,我们可以重点关注前者,也就是和编写并发程序相关的部分,这部分内容的核心就是Happens-Before规则。相信经过本章的介绍,你应该对这部分内容已经有了深入的认识。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
有一个共享变量 abc,在一个线程里设置了abc的值 abc=3,你思考一下,有哪些办法可以让其他线程能够看到abc==3?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
参考
|
||||||
|
|
||||||
|
|
||||||
|
JSR 133 (Java Memory Model) FAQ
|
||||||
|
Java内存模型FAQ
|
||||||
|
JSR-133: JavaTM Memory Model and Thread Specification
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
195
专栏/Java并发编程实战/03互斥锁(上):解决原子性问题.md
Normal file
195
专栏/Java并发编程实战/03互斥锁(上):解决原子性问题.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
03 互斥锁(上):解决原子性问题
|
||||||
|
在第一篇文章中我们提到,一个或者多个操作在CPU执行的过程中不被中断的特性,称为“原子性”。理解这个特性有助于你分析并发编程Bug出现的原因,例如利用它可以分析出long型变量在32位机器上读写可能出现的诡异Bug,明明已经把变量成功写入内存,重新读出来却不是自己写入的。
|
||||||
|
|
||||||
|
那原子性问题到底该如何解决呢?
|
||||||
|
|
||||||
|
你已经知道,原子性问题的源头是线程切换,如果能够禁用线程切换那不就能解决这个问题了吗?而操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断就能够禁止线程切换。
|
||||||
|
|
||||||
|
在早期单核CPU时代,这个方案的确是可行的,而且也有很多应用案例,但是并不适合多核场景。这里我们以32位CPU上执行long型变量的写操作为例来说明这个问题,long型变量是64位,在32位CPU上执行写操作会被拆分成两次写操作(写高32位和写低32位,如下图所示)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得CPU使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。
|
||||||
|
|
||||||
|
但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写long型变量高32位的话,那就有可能出现我们开头提及的诡异Bug了。
|
||||||
|
|
||||||
|
“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保证原子性了。
|
||||||
|
|
||||||
|
简易锁模型
|
||||||
|
|
||||||
|
当谈到互斥,相信聪明的你一定想到了那个杀手级解决方案:锁。同时大脑中还会出现以下模型:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
简易锁模型
|
||||||
|
|
||||||
|
我们把一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;否则呢就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁unlock()。
|
||||||
|
|
||||||
|
这个过程非常像办公室里高峰期抢占坑位,每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。很长时间里,我也是这么理解的。这样理解本身没有问题,但却很容易让我们忽视两个非常非常重要的点:我们锁的是什么?我们保护的又是什么?
|
||||||
|
|
||||||
|
改进后的锁模型
|
||||||
|
|
||||||
|
我们知道在现实世界里,锁和锁要保护的资源是有对应关系的,比如你用你家的锁保护你家的东西,我用我家的锁保护我家的东西。在并发编程世界里,锁和资源也应该有这个关系,但这个关系在我们上面的模型中是没有体现的,所以我们需要完善一下我们的模型。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
改进后的锁模型
|
||||||
|
|
||||||
|
首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源R;其次,我们要保护资源R就得为它创建一把锁LR;最后,针对这把锁LR,我们还需在进出临界区时添上加锁操作和解锁操作。另外,在锁LR和受保护资源之间,我特地用一条线做了关联,这个关联关系非常重要。很多并发Bug的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,这样的Bug非常不好诊断,因为潜意识里我们认为已经正确加锁了。
|
||||||
|
|
||||||
|
Java语言提供的锁技术:synchronized
|
||||||
|
|
||||||
|
锁是一种通用的技术方案,Java语言提供的synchronized关键字,就是锁的一种实现。synchronized关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本上都是下面这个样子:
|
||||||
|
|
||||||
|
class X {
|
||||||
|
// 修饰非静态方法
|
||||||
|
synchronized void foo() {
|
||||||
|
// 临界区
|
||||||
|
}
|
||||||
|
// 修饰静态方法
|
||||||
|
synchronized static void bar() {
|
||||||
|
// 临界区
|
||||||
|
}
|
||||||
|
// 修饰代码块
|
||||||
|
Object obj = new Object();
|
||||||
|
void baz() {
|
||||||
|
synchronized(obj) {
|
||||||
|
// 临界区
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
看完之后你可能会觉得有点奇怪,这个和我们上面提到的模型有点对不上号啊,加锁lock()和解锁unlock()在哪里呢?其实这两个操作都是有的,只是这两个操作是被Java默默加上的,Java编译器会在synchronized修饰的方法或代码块前后自动加上加锁lock()和解锁unlock(),这样做的好处就是加锁lock()和解锁unlock()一定是成对出现的,毕竟忘记解锁unlock()可是个致命的Bug(意味着其他线程只能死等下去了)。
|
||||||
|
|
||||||
|
那synchronized里的加锁lock()和解锁unlock()锁定的对象在哪里呢?上面的代码我们看到只有修饰代码块的时候,锁定了一个obj对象,那修饰方法的时候锁定的是什么呢?这个也是Java的一条隐式规则:
|
||||||
|
|
||||||
|
|
||||||
|
当修饰静态方法的时候,锁定的是当前类的Class对象,在上面的例子中就是Class X;-
|
||||||
|
当修饰非静态方法的时候,锁定的是当前实例对象this。
|
||||||
|
|
||||||
|
|
||||||
|
对于上面的例子,synchronized修饰静态方法相当于:
|
||||||
|
|
||||||
|
class X {
|
||||||
|
// 修饰静态方法
|
||||||
|
synchronized(X.class) static void bar() {
|
||||||
|
// 临界区
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
修饰非静态方法,相当于:
|
||||||
|
|
||||||
|
class X {
|
||||||
|
// 修饰非静态方法
|
||||||
|
synchronized(this) void foo() {
|
||||||
|
// 临界区
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
用synchronized解决count+=1问题
|
||||||
|
|
||||||
|
相信你一定记得我们前面文章中提到过的count+=1存在的并发问题,现在我们可以尝试用synchronized来小试牛刀一把,代码如下所示。SafeCalc这个类有两个方法:一个是get()方法,用来获得value的值;另一个是addOne()方法,用来给value加1,并且addOne()方法我们用synchronized修饰。那么我们使用的这两个方法有没有并发问题呢?
|
||||||
|
|
||||||
|
class SafeCalc {
|
||||||
|
long value = 0L;
|
||||||
|
long get() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
synchronized void addOne() {
|
||||||
|
value += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
我们先来看看addOne()方法,首先可以肯定,被synchronized修饰后,无论是单核CPU还是多核CPU,只有一个线程能够执行addOne()方法,所以一定能保证原子操作,那是否有可见性问题呢?要回答这问题,就要重温一下上一篇文章中提到的管程中锁的规则。
|
||||||
|
|
||||||
|
|
||||||
|
管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
|
||||||
|
|
||||||
|
|
||||||
|
管程,就是我们这里的synchronized(至于为什么叫管程,我们后面介绍),我们知道synchronized修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;而所谓“对一个锁解锁 Happens-Before 后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,综合Happens-Before的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。
|
||||||
|
|
||||||
|
按照这个规则,如果多个线程同时执行addOne()方法,可见性是可以保证的,也就说如果有1000个线程执行addOne()方法,最终结果一定是value的值增加了1000。看到这个结果,我们长出一口气,问题终于解决了。
|
||||||
|
|
||||||
|
但也许,你一不小心就忽视了get()方法。执行addOne()方法后,value的值对get()方法是可见的吗?这个可见性是没法保证的。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而get()方法并没有加锁操作,所以可见性没法保证。那如何解决呢?很简单,就是get()方法也synchronized一下,完整的代码如下所示。
|
||||||
|
|
||||||
|
class SafeCalc {
|
||||||
|
long value = 0L;
|
||||||
|
synchronized long get() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
synchronized void addOne() {
|
||||||
|
value += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
上面的代码转换为我们提到的锁模型,就是下面图示这个样子。get()方法和addOne()方法都需要访问value这个受保护的资源,这个资源用this这把锁来保护。线程要进入临界区get()和addOne(),必须先获得this这把锁,这样get()和addOne()也是互斥的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
保护临界区get()和addOne()的示意图
|
||||||
|
|
||||||
|
这个模型更像现实世界里面球赛门票的管理,一个座位只允许一个人使用,这个座位就是“受保护资源”,球场的入口就是Java类里的方法,而门票就是用来保护资源的“锁”,Java里的检票工作是由synchronized解决的。
|
||||||
|
|
||||||
|
锁和受保护资源的关系
|
||||||
|
|
||||||
|
我们前面提到,受保护资源和锁之间的关联关系非常重要,他们的关系是怎样的呢?一个合理的关系是:受保护资源和锁之间的关联关系是N:1的关系。还拿前面球赛门票的管理来类比,就是一个座位,我们只能用一张票来保护,如果多发了重复的票,那就要打架了。现实世界里,我们可以用多把锁来保护同一个资源,但在并发领域是不行的,并发领域的锁和现实世界的锁不是完全匹配的。不过倒是可以用同一把锁来保护多个资源,这个对应到现实世界就是我们所谓的“包场”了。
|
||||||
|
|
||||||
|
上面那个例子我稍作改动,把value改成静态变量,把addOne()方法改成静态方法,此时get()方法和addOne()方法是否存在并发问题呢?
|
||||||
|
|
||||||
|
class SafeCalc {
|
||||||
|
static long value = 0L;
|
||||||
|
synchronized long get() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
synchronized static void addOne() {
|
||||||
|
value += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
如果你仔细观察,就会发现改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量value,两个锁分别是this和SafeCalc.class。我们可以用下面这幅图来形象描述这个关系。由于临界区get()和addOne()是用两个锁保护的,因此这两个临界区没有互斥关系,临界区addOne()对value的修改对临界区get()也没有可见性保证,这就导致并发问题了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
两把锁保护一个资源的示意图
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
互斥锁,在并发领域的知名度极高,只要有了并发问题,大家首先容易想到的就是加锁,因为大家都知道,加锁能够保证执行临界区代码的互斥性。这样理解虽然正确,但是却不能够指导你真正用好互斥锁。临界区的代码是操作受保护资源的路径,类似于球场的入口,入口一定要检票,也就是要加锁,但不是随便一把锁都能有效。所以必须深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。
|
||||||
|
|
||||||
|
synchronized是Java在语言层面提供的互斥原语,其实Java里面还有很多其他类型的锁,但作为互斥锁,原理都是相通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁/解锁,就属于设计层面的事情了。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
下面的代码用synchronized修饰代码块来尝试解决并发问题,你觉得这个使用方式正确吗?有哪些问题呢?能解决可见性和原子性问题吗?
|
||||||
|
|
||||||
|
class SafeCalc {
|
||||||
|
long value = 0L;
|
||||||
|
long get() {
|
||||||
|
synchronized (new Object()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void addOne() {
|
||||||
|
synchronized (new Object()) {
|
||||||
|
value += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
178
专栏/Java并发编程实战/04互斥锁(下):如何用一把锁保护多个资源?.md
Normal file
178
专栏/Java并发编程实战/04互斥锁(下):如何用一把锁保护多个资源?.md
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
04 互斥锁(下):如何用一把锁保护多个资源?
|
||||||
|
在上一篇文章中,我们提到受保护资源和锁之间合理的关联关系应该是N:1的关系,也就是说可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源,并且结合文中示例,我们也重点强调了“不能用多把锁来保护一个资源”这个问题。而至于如何保护多个资源,我们今天就来聊聊。
|
||||||
|
|
||||||
|
当我们要保护多个资源时,首先要区分这些资源是否存在关联关系。
|
||||||
|
|
||||||
|
保护没有关联关系的多个资源
|
||||||
|
|
||||||
|
在现实世界里,球场的座位和电影院的座位就是没有关联关系的,这种场景非常容易解决,那就是球赛有球赛的门票,电影院有电影院的门票,各自管理各自的。
|
||||||
|
|
||||||
|
同样这对应到编程领域,也很容易解决。例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。
|
||||||
|
|
||||||
|
相关的示例代码如下,账户类Account有两个成员变量,分别是账户余额balance和账户密码password。取款withdraw()和查看余额getBalance()操作会访问账户余额balance,我们创建一个final对象balLock作为锁(类比球赛门票);而更改密码updatePassword()和查看密码getPassword()操作会修改账户密码password,我们创建一个final对象pwLock作为锁(类比电影票)。不同的资源用不同的锁保护,各自管各自的,很简单。
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
// 锁:保护账户余额
|
||||||
|
private final Object balLock
|
||||||
|
= new Object();
|
||||||
|
// 账户余额
|
||||||
|
private Integer balance;
|
||||||
|
// 锁:保护账户密码
|
||||||
|
private final Object pwLock
|
||||||
|
= new Object();
|
||||||
|
// 账户密码
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
// 取款
|
||||||
|
void withdraw(Integer amt) {
|
||||||
|
synchronized(balLock) {
|
||||||
|
if (this.balance > amt){
|
||||||
|
this.balance -= amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 查看余额
|
||||||
|
Integer getBalance() {
|
||||||
|
synchronized(balLock) {
|
||||||
|
return balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更改密码
|
||||||
|
void updatePassword(String pw){
|
||||||
|
synchronized(pwLock) {
|
||||||
|
this.password = pw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 查看密码
|
||||||
|
String getPassword() {
|
||||||
|
synchronized(pwLock) {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
当然,我们也可以用一把互斥锁来保护多个资源,例如我们可以用this这一把锁来管理账户类里所有的资源:账户余额和用户密码。具体实现很简单,示例程序中所有的方法都增加同步关键字synchronized就可以了,这里我就不一一展示了。
|
||||||
|
|
||||||
|
但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。
|
||||||
|
|
||||||
|
保护有关联关系的多个资源
|
||||||
|
|
||||||
|
如果多个资源是有关联关系的,那这个问题就有点复杂了。例如银行业务里面的转账操作,账户A减少100元,账户B增加100元。这两个账户就是有关联关系的。那对于像转账这种有关联关系的操作,我们应该怎么去解决呢?先把这个问题代码化。我们声明了个账户类:Account,该类有一个成员变量余额:balance,还有一个用于转账的方法:transfer(),然后怎么保证转账操作transfer()没有并发问题呢?
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
private int balance;
|
||||||
|
// 转账
|
||||||
|
void transfer(
|
||||||
|
Account target, int amt){
|
||||||
|
if (this.balance > amt) {
|
||||||
|
this.balance -= amt;
|
||||||
|
target.balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
相信你的直觉会告诉你这样的解决方案:用户synchronized关键字修饰一下transfer()方法就可以了,于是你很快就完成了相关的代码,如下所示。
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
private int balance;
|
||||||
|
// 转账
|
||||||
|
synchronized void transfer(
|
||||||
|
Account target, int amt){
|
||||||
|
if (this.balance > amt) {
|
||||||
|
this.balance -= amt;
|
||||||
|
target.balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
在这段代码中,临界区内有两个资源,分别是转出账户的余额this.balance和转入账户的余额target.balance,并且用的是一把锁this,符合我们前面提到的,多个资源可以用一把锁来保护,这看上去完全正确呀。真的是这样吗?可惜,这个方案仅仅是看似正确,为什么呢?
|
||||||
|
|
||||||
|
问题就出在this这把锁上,this这把锁可以保护自己的余额this.balance,却保护不了别人的余额target.balance,就像你不能用自家的锁来保护别人家的资产,也不能用自己的票来保护别人的座位一样。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
用锁this保护this.balance和target.balance的示意图
|
||||||
|
|
||||||
|
下面我们具体分析一下,假设有A、B、C三个账户,余额都是200元,我们用两个线程分别执行两个转账操作:账户A转给账户B 100 元,账户B转给账户C 100 元,最后我们期望的结果应该是账户A的余额是100元,账户B的余额是200元, 账户C的余额是300元。
|
||||||
|
|
||||||
|
我们假设线程1执行账户A转账户B的操作,线程2执行账户B转账户C的操作。这两个线程分别在两颗CPU上同时执行,那它们是互斥的吗?我们期望是,但实际上并不是。因为线程1锁定的是账户A的实例(A.this),而线程2锁定的是账户B的实例(B.this),所以这两个线程可以同时进入临界区transfer()。同时进入临界区的结果是什么呢?线程1和线程2都会读到账户B的余额为200,导致最终账户B的余额可能是300(线程1后于线程2写B.balance,线程2写的B.balance值被线程1覆盖),可能是100(线程1先于线程2写B.balance,线程1写的B.balance值被线程2覆盖),就是不可能是200。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
并发转账示意图
|
||||||
|
|
||||||
|
使用锁的正确姿势
|
||||||
|
|
||||||
|
在上一篇文章中,我们提到用同一把锁来保护多个资源,也就是现实世界的“包场”,那在编程领域应该怎么“包场”呢?很简单,只要我们的锁能覆盖所有受保护资源就可以了。在上面的例子中,this是对象级别的锁,所以A对象和B对象都有自己的锁,如何让A对象和B对象共享一把锁呢?
|
||||||
|
|
||||||
|
稍微开动脑筋,你会发现其实方案还挺多的,比如可以让所有对象都持有一个唯一性的对象,这个对象在创建Account时传入。方案有了,完成代码就简单了。示例代码如下,我们把Account默认构造函数变为private,同时增加一个带Object lock参数的构造函数,创建Account对象时,传入相同的lock,这样所有的Account对象都会共享这个lock了。
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
private Object lock;
|
||||||
|
private int balance;
|
||||||
|
private Account();
|
||||||
|
// 创建Account时传入同一个lock对象
|
||||||
|
public Account(Object lock) {
|
||||||
|
this.lock = lock;
|
||||||
|
}
|
||||||
|
// 转账
|
||||||
|
void transfer(Account target, int amt){
|
||||||
|
// 此处检查所有对象共享的锁
|
||||||
|
synchronized(lock) {
|
||||||
|
if (this.balance > amt) {
|
||||||
|
this.balance -= amt;
|
||||||
|
target.balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这个办法确实能解决问题,但是有点小瑕疵,它要求在创建Account对象的时候必须传入同一个对象,如果创建Account对象时,传入的lock不是同一个对象,那可就惨了,会出现锁自家门来保护他家资产的荒唐事。在真实的项目场景中,创建Account对象的代码很可能分散在多个工程中,传入共享的lock真的很难。
|
||||||
|
|
||||||
|
所以,上面的方案缺乏实践的可行性,我们需要更好的方案。还真有,就是用Account.class作为共享的锁。Account.class是所有Account对象共享的,而且这个对象是Java虚拟机在加载Account类的时候创建的,所以我们不用担心它的唯一性。使用Account.class作为共享的锁,我们就无需在创建Account对象时传入了,代码更简单。
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
private int balance;
|
||||||
|
// 转账
|
||||||
|
void transfer(Account target, int amt){
|
||||||
|
synchronized(Account.class) {
|
||||||
|
if (this.balance > amt) {
|
||||||
|
this.balance -= amt;
|
||||||
|
target.balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
下面这幅图很直观地展示了我们是如何使用共享的锁Account.class来保护不同对象的临界区的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
相信你看完这篇文章后,对如何保护多个资源已经很有心得了,关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁,这个过程可以类比一下门票管理。
|
||||||
|
|
||||||
|
我们再引申一下上面提到的关联关系,关联关系如果用更具体、更专业的语言来描述的话,其实是一种“原子性”特征,在前面的文章中,我们提到的原子性,主要是面向CPU指令的,转账操作的原子性则是属于是面向高级语言的,不过它们本质上是一样的。
|
||||||
|
|
||||||
|
“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在32位的机器上写long型变量有中间状态(只写了64位中的32位),在银行转账的操作中也有中间状态(账户A减少了100,账户B还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
在第一个示例程序里,我们用了两把不同的锁来分别保护账户余额、账户密码,创建锁的时候,我们用的是:private final Object xxxLock = new Object();,如果账户余额用 this.balance 作为互斥锁,账户密码用this.password作为互斥锁,你觉得是否可以呢?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
235
专栏/Java并发编程实战/05一不小心就死锁了,怎么办?.md
Normal file
235
专栏/Java并发编程实战/05一不小心就死锁了,怎么办?.md
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
05 一不小心就死锁了,怎么办?
|
||||||
|
在上一篇文章中,我们用Account.class作为互斥锁,来解决银行业务里面的转账问题,虽然这个方案不存在并发问题,但是所有账户的转账操作都是串行的,例如账户A 转账户B、账户C 转账户D这两个转账操作现实世界里是可以并行的,但是在这个方案里却被串行化了,这样的话,性能太差。
|
||||||
|
|
||||||
|
试想互联网支付盛行的当下,8亿网民每人每天一笔交易,每天就是8亿笔交易;每笔交易都对应着一次转账操作,8亿笔交易就是8亿次转账操作,也就是说平均到每秒就是近1万次转账操作,若所有的转账操作都串行,性能完全不能接受。
|
||||||
|
|
||||||
|
那下面我们就尝试着把性能提升一下。
|
||||||
|
|
||||||
|
向现实世界要答案
|
||||||
|
|
||||||
|
现实世界里,账户转账操作是支持并发的,而且绝对是真正的并行,银行所有的窗口都可以做转账操作。只要我们能仿照现实世界做转账操作,串行的问题就解决了。
|
||||||
|
|
||||||
|
我们试想在古代,没有信息化,账户的存在形式真的就是一个账本,而且每个账户都有一个账本,这些账本都统一存放在文件架上。银行柜员在给我们做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。这个柜员在拿账本的时候可能遇到以下三种情况:
|
||||||
|
|
||||||
|
|
||||||
|
文件架上恰好有转出账本和转入账本,那就同时拿走;
|
||||||
|
如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其他柜员把另外一个账本送回来;
|
||||||
|
转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。
|
||||||
|
|
||||||
|
|
||||||
|
上面这个过程在编程的世界里怎么实现呢?其实用两把锁就实现了,转出账本一把,转入账本另一把。在transfer()方法内部,我们首先尝试锁定转出账户this(先把转出账本拿到手),然后尝试锁定转入账户target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。这个逻辑可以图形化为下图这个样子。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
两个转账操作并行示意图
|
||||||
|
|
||||||
|
而至于详细的代码实现,如下所示。经过这样的优化后,账户A 转账户B和账户C 转账户D这两个转账操作就可以并行了。
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
private int balance;
|
||||||
|
// 转账
|
||||||
|
void transfer(Account target, int amt){
|
||||||
|
// 锁定转出账户
|
||||||
|
synchronized(this) {
|
||||||
|
// 锁定转入账户
|
||||||
|
synchronized(target) {
|
||||||
|
if (this.balance > amt) {
|
||||||
|
this.balance -= amt;
|
||||||
|
target.balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
没有免费的午餐
|
||||||
|
|
||||||
|
上面的实现看上去很完美,并且也算是将锁用得出神入化了。相对于用Account.class作为互斥锁,锁定的范围太大,而我们锁定两个账户范围就小多了,这样的锁,上一章我们介绍过,叫细粒度锁。使用细粒度锁可以提高并行度,是性能优化的一个重要手段。
|
||||||
|
|
||||||
|
这个时候可能你已经开始警觉了,使用细粒度锁这么简单,有这样的好事,是不是也要付出点什么代价啊?编写并发程序就需要这样时时刻刻保持谨慎。
|
||||||
|
|
||||||
|
的确,使用细粒度锁是有代价的,这个代价就是可能会导致死锁。
|
||||||
|
|
||||||
|
在详细介绍死锁之前,我们先看看现实世界里的一种特殊场景。如果有客户找柜员张三做个转账业务:账户A 转账户B 100元,此时另一个客户找柜员李四也做个转账业务:账户B 转账户A 100 元,于是张三和李四同时都去文件架上拿账本,这时候有可能凑巧张三拿到了账本A,李四拿到了账本B。张三拿到账本A后就等着账本B(账本B已经被李四拿走),而李四拿到账本B后就等着账本A(账本A已经被张三拿走),他们要等多久呢?他们会永远等待下去…因为张三不会把账本A送回去,李四也不会把账本B送回去。我们姑且称为死等吧。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
转账业务中的“死等”
|
||||||
|
|
||||||
|
现实世界里的死等,就是编程领域的死锁了。死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
|
||||||
|
|
||||||
|
上面转账的代码是怎么发生死锁的呢?我们假设线程T1执行账户A转账户B的操作,账户A.transfer(账户B);同时线程T2执行账户B转账户A的操作,账户B.transfer(账户A)。当T1和T2同时执行完①处的代码时,T1获得了账户A的锁(对于T1,this是账户A),而T2获得了账户B的锁(对于T2,this是账户B)。之后T1和T2在执行②处的代码时,T1试图获取账户B的锁时,发现账户B已经被锁定(被T2锁定),所以T1开始等待;T2则试图获取账户A的锁时,发现账户A已经被锁定(被T1锁定),所以T2也开始等待。于是T1和T2会无期限地等待下去,也就是我们所说的死锁了。
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
private int balance;
|
||||||
|
// 转账
|
||||||
|
void transfer(Account target, int amt){
|
||||||
|
// 锁定转出账户
|
||||||
|
synchronized(this){ ①
|
||||||
|
// 锁定转入账户
|
||||||
|
synchronized(target){ ②
|
||||||
|
if (this.balance > amt) {
|
||||||
|
this.balance -= amt;
|
||||||
|
target.balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
关于这种现象,我们还可以借助资源分配图来可视化锁的占用情况(资源分配图是个有向图,它可以描述资源和线程的状态)。其中,资源用方形节点表示,线程用圆形节点表示;资源中的点指向线程的边表示线程已经获得该资源,线程指向资源的边则表示线程请求资源,但尚未得到。转账发生死锁时的资源分配图就如下图所示,一个“各据山头死等”的尴尬局面。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
转账发生死锁时的资源分配图
|
||||||
|
|
||||||
|
如何预防死锁
|
||||||
|
|
||||||
|
并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。
|
||||||
|
|
||||||
|
那如何避免死锁呢?要避免死锁就需要分析死锁发生的条件,有个叫Coffman的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:
|
||||||
|
|
||||||
|
|
||||||
|
互斥,共享资源X和Y只能被一个线程占用;
|
||||||
|
占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
|
||||||
|
不可抢占,其他线程不能强行抢占线程T1占有的资源;
|
||||||
|
循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待。
|
||||||
|
|
||||||
|
|
||||||
|
反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。
|
||||||
|
|
||||||
|
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?
|
||||||
|
|
||||||
|
|
||||||
|
对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
|
||||||
|
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
|
||||||
|
对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
|
||||||
|
|
||||||
|
|
||||||
|
我们已经从理论上解决了如何预防死锁,那具体如何体现在代码上呢?下面我们就来尝试用代码实践一下这些理论。
|
||||||
|
|
||||||
|
1. 破坏占用且等待条件
|
||||||
|
|
||||||
|
从理论上讲,要破坏这个条件,可以一次性申请所有资源。在现实世界里,就拿前面我们提到的转账操作来讲,它需要的资源有两个,一个是转出账户,另一个是转入账户,当这两个账户同时被申请时,我们该怎么解决这个问题呢?
|
||||||
|
|
||||||
|
可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,也就是说柜员不能直接在文件架上拿账本,必须通过账本管理员才能拿到想要的账本。例如,张三同时申请账本A和B,账本管理员如果发现文件架上只有账本A,这个时候账本管理员是不会把账本A拿下来给张三的,只有账本A和B都在的时候才会给张三。这样就保证了“一次性申请所有资源”。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
通过账本管理员拿账本
|
||||||
|
|
||||||
|
对应到编程领域,“同时申请”这个操作是一个临界区,我们也需要一个角色(Java里面的类)来管理这个临界区,我们就把这个角色定为Allocator。它有两个重要功能,分别是:同时申请资源apply()和同时释放资源free()。账户Account 类里面持有一个Allocator的单例(必须是单例,只能由一个人来分配资源)。当账户Account在执行转账操作的时候,首先向Allocator同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知Allocator同时释放转出账户和转入账户这两个资源。具体的代码实现如下。
|
||||||
|
|
||||||
|
class Allocator {
|
||||||
|
private List<Object> als =
|
||||||
|
new ArrayList<>();
|
||||||
|
// 一次性申请所有资源
|
||||||
|
synchronized boolean apply(
|
||||||
|
Object from, Object to){
|
||||||
|
if(als.contains(from) ||
|
||||||
|
als.contains(to)){
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
als.add(from);
|
||||||
|
als.add(to);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 归还资源
|
||||||
|
synchronized void free(
|
||||||
|
Object from, Object to){
|
||||||
|
als.remove(from);
|
||||||
|
als.remove(to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
// actr应该为单例
|
||||||
|
private Allocator actr;
|
||||||
|
private int balance;
|
||||||
|
// 转账
|
||||||
|
void transfer(Account target, int amt){
|
||||||
|
// 一次性申请转出账户和转入账户,直到成功
|
||||||
|
while(!actr.apply(this, target))
|
||||||
|
;
|
||||||
|
try{
|
||||||
|
// 锁定转出账户
|
||||||
|
synchronized(this){
|
||||||
|
// 锁定转入账户
|
||||||
|
synchronized(target){
|
||||||
|
if (this.balance > amt){
|
||||||
|
this.balance -= amt;
|
||||||
|
target.balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
actr.free(this, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
2. 破坏不可抢占条件
|
||||||
|
|
||||||
|
破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点synchronized是做不到的。原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
|
||||||
|
|
||||||
|
你可能会质疑,“Java作为排行榜第一的语言,这都解决不了?”你的怀疑很有道理,Java在语言层次确实没有解决这个问题,不过在SDK层面还是解决了的,java.util.concurrent这个包下面提供的Lock是可以轻松解决这个问题的。关于这个话题,咱们后面会详细讲。
|
||||||
|
|
||||||
|
3. 破坏循环等待条件
|
||||||
|
|
||||||
|
破坏这个条件,需要对资源进行排序,然后按序申请资源。这个实现非常简单,我们假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如下面代码中,①~⑥处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
private int id;
|
||||||
|
private int balance;
|
||||||
|
// 转账
|
||||||
|
void transfer(Account target, int amt){
|
||||||
|
Account left = this ①
|
||||||
|
Account right = target; ②
|
||||||
|
if (this.id > target.id) { ③
|
||||||
|
left = target; ④
|
||||||
|
right = this; ⑤
|
||||||
|
} ⑥
|
||||||
|
// 锁定序号小的账户
|
||||||
|
synchronized(left){
|
||||||
|
// 锁定序号大的账户
|
||||||
|
synchronized(right){
|
||||||
|
if (this.balance > amt){
|
||||||
|
this.balance -= amt;
|
||||||
|
target.balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
当我们在编程世界里遇到问题时,应不局限于当下,可以换个思路,向现实世界要答案,利用现实世界的模型来构思解决方案,这样往往能够让我们的方案更容易理解,也更能够看清楚问题的本质。
|
||||||
|
|
||||||
|
但是现实世界的模型有些细节往往会被我们忽视。因为在现实世界里,人太智能了,以致有些细节实在是显得太不重要了。在转账的模型中,我们为什么会忽视死锁问题呢?原因主要是在现实世界,我们会交流,并且会很智能地交流。而编程世界里,两个线程是不会智能地交流的。所以在利用现实模型建模的时候,我们还要仔细对比现实世界和编程世界里的各角色之间的差异。
|
||||||
|
|
||||||
|
我们今天这一篇文章主要讲了用细粒度锁来锁定多个资源时,要注意死锁的问题。这个就需要你能把它强化为一个思维定势,遇到这种场景,马上想到可能存在死锁问题。当你知道风险之后,才有机会谈如何预防和避免,因此,识别出风险很重要。
|
||||||
|
|
||||||
|
预防死锁主要是破坏三个条件中的一个,有了这个思路后,实现就简单了。但仍需注意的是,有时候预防死锁成本也是很高的。例如上面转账那个例子,我们破坏占用且等待条件的成本就比破坏循环等待条件的成本高,破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));方法,不过好在apply()这个方法基本不耗时。 在转账这个例子中,破坏循环等待条件就是成本最低的一个方案。
|
||||||
|
|
||||||
|
所以我们在选择具体方案的时候,还需要评估一下操作成本,从中选择一个成本最低的方案。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
我们上面提到:破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));这个方法,那它比synchronized(Account.class)有没有性能优势呢?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
139
专栏/Java并发编程实战/06用“等待-通知”机制优化循环等待.md
Normal file
139
专栏/Java并发编程实战/06用“等待-通知”机制优化循环等待.md
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
06 用“等待-通知”机制优化循环等待
|
||||||
|
由上一篇文章你应该已经知道,在破坏占用且等待条件的时候,如果转出账本和转入账本不满足同时在文件架上这个条件,就用死循环的方式来循环等待,核心代码如下:
|
||||||
|
|
||||||
|
// 一次性申请转出账户和转入账户,直到成功
|
||||||
|
while(!actr.apply(this, target))
|
||||||
|
;
|
||||||
|
|
||||||
|
|
||||||
|
如果apply()操作耗时非常短,而且并发冲突量也不大时,这个方案还挺不错的,因为这种场景下,循环上几次或者几十次就能一次性获取转出账户和转入账户了。但是如果apply()操作耗时长,或者并发冲突量大的时候,循环等待这种方案就不适用了,因为在这种场景下,可能要循环上万次才能获取到锁,太消耗CPU了。
|
||||||
|
|
||||||
|
其实在这种场景下,最好的方案应该是:如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;当线程要求的条件(转出账本和转入账本同在文件架上)满足后,通知等待的线程重新执行。其中,使用线程阻塞的方式就能避免循环等待消耗CPU的问题。
|
||||||
|
|
||||||
|
那Java语言是否支持这种等待-通知机制呢?答案是:一定支持(毕竟占据排行榜第一那么久)。下面我们就来看看Java语言是如何支持等待-通知机制的。
|
||||||
|
|
||||||
|
完美的就医流程
|
||||||
|
|
||||||
|
在介绍Java语言如何支持等待-通知机制之前,我们先看一个现实世界里面的就医流程,因为它有着完善的等待-通知机制,所以对比就医流程,我们就能更好地理解和应用并发编程中的等待-通知机制。
|
||||||
|
|
||||||
|
就医流程基本上是这样:
|
||||||
|
|
||||||
|
|
||||||
|
患者先去挂号,然后到就诊门口分诊,等待叫号;
|
||||||
|
当叫到自己的号时,患者就可以找大夫就诊了;
|
||||||
|
就诊过程中,大夫可能会让患者去做检查,同时叫下一位患者;
|
||||||
|
当患者做完检查后,拿检测报告重新分诊,等待叫号;
|
||||||
|
当大夫再次叫到自己的号时,患者再去找大夫就诊。
|
||||||
|
|
||||||
|
|
||||||
|
或许你已经发现了,这个有着完美等待-通知机制的就医流程,不仅能够保证同一时刻大夫只为一个患者服务,而且还能够保证大夫和患者的效率。与此同时你可能也会有疑问,“这个就医流程很复杂呀,我们前面描述的等待-通知机制相较而言是不是太简单了?”那这个复杂度是否是必须的呢?这个是必须的,我们不能忽视等待-通知机制中的一些细节。
|
||||||
|
|
||||||
|
下面我们来对比看一下前面都忽视了哪些细节。
|
||||||
|
|
||||||
|
|
||||||
|
患者到就诊门口分诊,类似于线程要去获取互斥锁;当患者被叫到时,类似线程已经获取到锁了。
|
||||||
|
大夫让患者去做检查(缺乏检测报告不能诊断病因),类似于线程要求的条件没有满足。
|
||||||
|
患者去做检查,类似于线程进入等待状态;然后大夫叫下一个患者,这个步骤我们在前面的等待-通知机制中忽视了,这个步骤对应到程序里,本质是线程释放持有的互斥锁。
|
||||||
|
患者做完检查,类似于线程要求的条件已经满足;患者拿检测报告重新分诊,类似于线程需要重新获取互斥锁,这个步骤我们在前面的等待-通知机制中也忽视了。
|
||||||
|
|
||||||
|
|
||||||
|
所以加上这些至关重要的细节,综合一下,就可以得出一个完整的等待-通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
|
||||||
|
|
||||||
|
用synchronized实现等待-通知机制
|
||||||
|
|
||||||
|
在Java语言里,等待-通知机制可以有多种实现方式,比如Java语言内置的synchronized配合wait()、notify()、notifyAll()这三个方法就能轻松实现。
|
||||||
|
|
||||||
|
如何用synchronized实现互斥锁,你应该已经很熟悉了。在下面这个图里,左边有一个等待队列,同一时刻,只允许一个线程进入synchronized保护的临界区(这个临界区可以看作大夫的诊室),当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待(相当于患者分诊等待)。这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
wait()操作工作原理图
|
||||||
|
|
||||||
|
在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java对象的wait()方法就能够满足这种需求。如上图所示,当调用wait()方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。
|
||||||
|
|
||||||
|
那线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是Java对象的notify()和notifyAll()方法。我在下面这个图里为你大致描述了这个过程,当条件满足时调用notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
notify()操作工作原理图
|
||||||
|
|
||||||
|
为什么说是曾经满足过呢?因为notify()只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。这一点你需要格外注意。
|
||||||
|
|
||||||
|
除此之外,还有一个需要注意的点,被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用wait()时已经释放了)。
|
||||||
|
|
||||||
|
上面我们一直强调wait()、notify()、notifyAll()方法操作的等待队列是互斥锁的等待队列,所以如果synchronized锁定的是this,那么对应的一定是this.wait()、this.notify()、this.notifyAll();如果synchronized锁定的是target,那么对应的一定是target.wait()、target.notify()、target.notifyAll() 。而且wait()、notify()、notifyAll()这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现wait()、notify()、notifyAll()都是在synchronized{}内部被调用的。如果在synchronized{}外部调用,或者锁定的this,而用target.wait()调用的话,JVM会抛出一个运行时异常:java.lang.IllegalMonitorStateException。
|
||||||
|
|
||||||
|
小试牛刀:一个更好地资源分配器
|
||||||
|
|
||||||
|
等待-通知机制的基本原理搞清楚后,我们就来看看它如何解决一次性申请转出账户和转入账户的问题吧。在这个等待-通知机制中,我们需要考虑以下四个要素。
|
||||||
|
|
||||||
|
|
||||||
|
互斥锁:上一篇文章我们提到Allocator需要是单例的,所以我们可以用this作为互斥锁。
|
||||||
|
线程要求的条件:转出账户和转入账户都没有被分配过。
|
||||||
|
何时等待:线程要求的条件不满足就等待。
|
||||||
|
何时通知:当有线程释放账户时就通知。
|
||||||
|
|
||||||
|
|
||||||
|
将上面几个问题考虑清楚,可以快速完成下面的代码。需要注意的是我们使用了:
|
||||||
|
|
||||||
|
while(条件不满足) {
|
||||||
|
wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
利用这种范式可以解决上面提到的条件曾经满足过这个问题。因为当wait()返回时,有可能条件已经发生变化了,曾经条件满足,但是现在已经不满足了,所以要重新检验条件是否满足。范式,意味着是经典做法,所以没有特殊理由不要尝试换个写法。后面在介绍“管程”的时候,我会详细介绍这个经典做法的前世今生。
|
||||||
|
|
||||||
|
class Allocator {
|
||||||
|
private List<Object> als;
|
||||||
|
// 一次性申请所有资源
|
||||||
|
synchronized void apply(
|
||||||
|
Object from, Object to){
|
||||||
|
// 经典写法
|
||||||
|
while(als.contains(from) ||
|
||||||
|
als.contains(to)){
|
||||||
|
try{
|
||||||
|
wait();
|
||||||
|
}catch(Exception e){
|
||||||
|
}
|
||||||
|
}
|
||||||
|
als.add(from);
|
||||||
|
als.add(to);
|
||||||
|
}
|
||||||
|
// 归还资源
|
||||||
|
synchronized void free(
|
||||||
|
Object from, Object to){
|
||||||
|
als.remove(from);
|
||||||
|
als.remove(to);
|
||||||
|
notifyAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
尽量使用notifyAll()
|
||||||
|
|
||||||
|
在上面的代码中,我用的是notifyAll()来实现通知机制,为什么不使用notify()呢?这二者是有区别的,notify()是会随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中的所有线程。从感觉上来讲,应该是notify()更好一些,因为即便通知所有线程,也只有一个线程能够进入临界区。但那所谓的感觉往往都蕴藏着风险,实际上使用notify()也很有风险,它的风险在于可能导致某些线程永远不会被通知到。
|
||||||
|
|
||||||
|
假设我们有资源A、B、C、D,线程1申请到了AB,线程2申请到了CD,此时线程3申请AB,会进入等待队列(AB分配给线程1,线程3要求的条件不满足),线程4申请CD也会进入等待队列。我们再假设之后线程1归还了资源AB,如果使用notify()来通知等待队列中的线程,有可能被通知的是线程4,但线程4申请的是CD,所以此时线程4还是会继续等待,而真正该唤醒的线程3就再也没有机会被唤醒了。
|
||||||
|
|
||||||
|
所以除非经过深思熟虑,否则尽量使用notifyAll()。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
等待-通知机制是一种非常普遍的线程间协作的方式。工作中经常看到有同学使用轮询的方式来等待某个状态,其实很多情况下都可以用今天我们介绍的等待-通知机制来优化。Java语言内置的synchronized配合wait()、notify()、notifyAll()这三个方法可以快速实现这种机制,但是它们的使用看上去还是有点复杂,所以你需要认真理解等待队列和wait()、notify()、notifyAll()的关系。最好用现实世界做个类比,这样有助于你的理解。
|
||||||
|
|
||||||
|
Java语言的这种实现,背后的理论模型其实是管程,这个很重要,不过你不用担心,后面会有专门的一章来介绍管程。现在你只需要能够熟练使用就可以了。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
很多面试都会问到,wait()方法和sleep()方法都能让当前线程挂起一段时间,那它们的区别是什么?现在你也试着回答一下吧。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
153
专栏/Java并发编程实战/07安全性、活跃性以及性能问题.md
Normal file
153
专栏/Java并发编程实战/07安全性、活跃性以及性能问题.md
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
07 安全性、活跃性以及性能问题
|
||||||
|
通过前面六篇文章,我们开启了一个简单的并发旅程,相信现在你对并发编程需要注意的问题已经有了更深入的理解,这是一个很大的进步,正所谓只有发现问题,才能解决问题。但是前面六篇文章的知识点可能还是有点分散,所以是时候将其总结一下了。
|
||||||
|
|
||||||
|
并发编程中我们需要注意的问题有很多,很庆幸前人已经帮我们总结过了,主要有三个方面,分别是:安全性问题、活跃性问题和性能问题。下面我就来一一介绍这些问题。
|
||||||
|
|
||||||
|
安全性问题
|
||||||
|
|
||||||
|
相信你一定听说过类似这样的描述:这个方法不是线程安全的,这个类不是线程安全的,等等。
|
||||||
|
|
||||||
|
那什么是线程安全呢?其实本质上就是正确性,而正确性的含义就是程序按照我们期望的执行,不要让我们感到意外。在第一篇《可见性、原子性和有序性问题:并发编程Bug的源头》中,我们已经见识过很多诡异的Bug,都是出乎我们预料的,它们都没有按照我们期望的执行。
|
||||||
|
|
||||||
|
那如何才能写出线程安全的程序呢?第一篇文章中已经介绍了并发Bug的三个主要源头:原子性问题、可见性问题和有序性问题。也就是说,理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题。
|
||||||
|
|
||||||
|
那是不是所有的代码都需要认真分析一遍是否存在这三个问题呢?当然不是,其实只有一种情况需要:存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。那如果能够做到不共享数据或者数据状态不发生变化,不就能够保证线程的安全性了嘛。有不少技术方案都是基于这个理论的,例如线程本地存储(Thread Local Storage,TLS)、不变模式等等,后面我会详细介绍相关的技术方案是如何在Java语言中实现的。
|
||||||
|
|
||||||
|
但是,现实生活中,必须共享会发生变化的数据,这样的应用场景还是很多的。
|
||||||
|
|
||||||
|
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发Bug,对此还有一个专业的术语,叫做数据竞争(Data Race)。比如,前面第一篇文章里有个add10K()的方法,当多个线程调用时候就会发生数据竞争,如下所示。
|
||||||
|
|
||||||
|
public class Test {
|
||||||
|
private long count = 0;
|
||||||
|
void add10K() {
|
||||||
|
int idx = 0;
|
||||||
|
while(idx++ < 10000) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
那是不是在访问数据的地方,我们加个锁保护一下就能解决所有的并发问题了呢?显然没有这么简单。例如,对于上面示例,我们稍作修改,增加两个被 synchronized 修饰的get()和set()方法, add10K()方法里面通过get()和set()方法来访问value变量,修改后的代码如下所示。对于修改后的代码,所有访问共享变量value的地方,我们都增加了互斥锁,此时是不存在数据竞争的。但很显然修改后的add10K()方法并不是线程安全的。
|
||||||
|
|
||||||
|
public class Test {
|
||||||
|
private long count = 0;
|
||||||
|
synchronized long get(){
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
synchronized void set(long v){
|
||||||
|
count = v;
|
||||||
|
}
|
||||||
|
void add10K() {
|
||||||
|
int idx = 0;
|
||||||
|
while(idx++ < 10000) {
|
||||||
|
set(get()+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
假设count=0,当两个线程同时执行get()方法时,get()方法会返回相同的值0,两个线程执行get()+1操作,结果都是1,之后两个线程再将结果1写入了内存。你本来期望的是2,而结果却是1。
|
||||||
|
|
||||||
|
这种问题,有个官方的称呼,叫竞态条件(Race Condition)。所谓竞态条件,指的是程序的执行结果依赖线程执行的顺序。例如上面的例子,如果两个线程完全同时执行,那么结果是1;如果两个线程是前后执行,那么结果就是2。在并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题,那就意味着程序执行的结果是不确定的,而执行结果不确定这可是个大Bug。
|
||||||
|
|
||||||
|
下面再结合一个例子来说明下竞态条件,就是前面文章中提到的转账操作。转账操作里面有个判断条件——转出金额不能大于账户余额,但在并发环境里面,如果不加控制,当多个线程同时对一个账号执行转出操作时,就有可能出现超额转出问题。假设账户A有余额200,线程1和线程2都要从账户A转出150,在下面的代码里,有可能线程1和线程2同时执行到第6行,这样线程1和线程2都会发现转出金额150小于账户余额200,于是就会发生超额转出的情况。
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
private int balance;
|
||||||
|
// 转账
|
||||||
|
void transfer(
|
||||||
|
Account target, int amt){
|
||||||
|
if (this.balance > amt) {
|
||||||
|
this.balance -= amt;
|
||||||
|
target.balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
所以你也可以按照下面这样来理解竞态条件。在并发场景中,程序的执行依赖于某个状态变量,也就是类似于下面这样:
|
||||||
|
|
||||||
|
if (状态变量 满足 执行条件) {
|
||||||
|
执行操作
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
当某个线程发现状态变量满足执行条件后,开始执行操作;可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。当然很多场景下,这个条件不是显式的,例如前面addOne的例子中,set(get()+1)这个复合操作,其实就隐式依赖get()的结果。
|
||||||
|
|
||||||
|
那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?其实这两类问题,都可以用互斥这个技术方案,而实现互斥的方案有很多,CPU提供了相关的互斥指令,操作系统、编程语言也会提供相关的API。从逻辑上来看,我们可以统一归为:锁。前面几章我们也粗略地介绍了如何使用锁,相信你已经胸中有丘壑了,这里就不再赘述了,你可以结合前面的文章温故知新。
|
||||||
|
|
||||||
|
活跃性问题
|
||||||
|
|
||||||
|
所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。
|
||||||
|
|
||||||
|
通过前面的学习你已经知道,发生“死锁”后线程会互相等待,而且会一直等待下去,在技术上的表现形式是线程永久地“阻塞”了。
|
||||||
|
|
||||||
|
但有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”。可以类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。这种情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。
|
||||||
|
|
||||||
|
解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。例如上面的那个例子,路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。“等待一个随机时间”的方案虽然很简单,却非常有效,Raft这样知名的分布式一致性算法中也用到了它。
|
||||||
|
|
||||||
|
那“饥饿”该怎么去理解呢?所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。“不患寡,而患不均”,如果线程优先级“不均”,在CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
|
||||||
|
|
||||||
|
解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。
|
||||||
|
|
||||||
|
那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
|
||||||
|
|
||||||
|
性能问题
|
||||||
|
|
||||||
|
使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
|
||||||
|
|
||||||
|
所以我们要尽量减少串行,那串行对性能的影响是怎么样的呢?假设串行百分比是5%,我们用多核多线程相比单核单线程能提速多少呢?
|
||||||
|
|
||||||
|
有个阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力,它正好可以解决这个问题,具体公式如下:
|
||||||
|
|
||||||
|
\(S=\\frac{1}{(1-p)+\\frac{p}{n}}\)
|
||||||
|
|
||||||
|
公式里的n可以理解为CPU的核数,p可以理解为并行百分比,那(1-p)就是串行百分比了,也就是我们假设的5%。我们再假设CPU的核数(也就是n)无穷大,那加速比S的极限就是20。也就是说,如果我们的串行率是5%,那么我们无论采用什么技术,最高也就只能提高20倍的性能。
|
||||||
|
|
||||||
|
所以使用锁的时候一定要关注对性能的影响。 那怎么才能避免锁带来的性能问题呢?这个问题很复杂,Java SDK并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域的性能。
|
||||||
|
|
||||||
|
不过从方案层面,我们可以这样来解决这个问题。
|
||||||
|
|
||||||
|
第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储(Thread Local Storage, TLS)、写入时复制(Copy-on-write)、乐观锁等;Java并发包里面的原子类也是一种无锁的数据结构;Disruptor则是一个无锁的内存队列,性能都非常好……
|
||||||
|
|
||||||
|
第二,减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是Java并发包里的ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。
|
||||||
|
|
||||||
|
性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。
|
||||||
|
|
||||||
|
|
||||||
|
吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
|
||||||
|
延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
|
||||||
|
并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是1000的时候,延迟是50毫秒。
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。
|
||||||
|
|
||||||
|
我们在设计并发程序的时候,主要是从宏观出发,也就是要重点关注它的安全性、活跃性以及性能。安全性方面要注意数据竞争和竞态条件,活跃性方面需要注意死锁、活锁、饥饿等问题,性能方面我们虽然介绍了两个方案,但是遇到具体问题,你还是要具体分析,根据特定的场景选择合适的数据结构和算法。
|
||||||
|
|
||||||
|
要解决问题,首先要把问题分析清楚。同样,要写好并发程序,首先要了解并发程序相关的问题,经过这7章的内容,相信你一定对并发程序相关的问题有了深入的理解,同时对并发程序也一定心存敬畏,因为一不小心就出问题了。不过这恰恰也是一个很好的开始,因为你已经学会了分析并发问题,然后解决并发问题也就不远了。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
Java语言提供的Vector是一个线程安全的容器,有同学写了下面的代码,你看看是否存在并发问题呢?
|
||||||
|
|
||||||
|
void addIfNotExist(Vector v,
|
||||||
|
Object o){
|
||||||
|
if(!v.contains(o)) {
|
||||||
|
v.add(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
182
专栏/Java并发编程实战/08管程:并发编程的万能钥匙.md
Normal file
182
专栏/Java并发编程实战/08管程:并发编程的万能钥匙.md
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
08 管程:并发编程的万能钥匙
|
||||||
|
并发编程这个技术领域已经发展了半个世纪了,相关的理论和技术纷繁复杂。那有没有一种核心技术可以很方便地解决我们的并发问题呢?这个问题如果让我选择,我一定会选择管程技术。Java语言在1.5之前,提供的唯一的并发原语就是管程,而且1.5之后提供的SDK并发包,也是以管程技术为基础的。除此之外,C/C++、C#等高级语言也都支持管程。
|
||||||
|
|
||||||
|
可以这么说,管程就是一把解决并发问题的万能钥匙。
|
||||||
|
|
||||||
|
什么是管程
|
||||||
|
|
||||||
|
不知道你是否曾思考过这个问题:为什么Java在1.5之前仅仅提供了synchronized关键字及wait()、notify()、notifyAll()这三个看似从天而降的方法?在刚接触Java的时候,我以为它会提供信号量这种编程原语,因为操作系统原理课程告诉我,用信号量能解决所有并发问题,结果我发现不是。后来我找到了原因:Java采用的是管程技术,synchronized关键字及wait()、notify()、notifyAll()这三个方法都是管程的组成部分。而管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。但是管程更容易使用,所以Java选择了管程。
|
||||||
|
|
||||||
|
管程,对应的英文是Monitor,很多Java领域的同学都喜欢将其翻译成“监视器”,这是直译。操作系统领域一般都翻译成“管程”,这个是意译,而我自己也更倾向于使用“管程”。
|
||||||
|
|
||||||
|
所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为Java领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。那管程是怎么管的呢?
|
||||||
|
|
||||||
|
MESA模型
|
||||||
|
|
||||||
|
在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen模型、Hoare模型和MESA模型。其中,现在广泛应用的是MESA模型,并且Java管程的实现参考的也是MESA模型。所以今天我们重点介绍一下MESA模型。
|
||||||
|
|
||||||
|
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。
|
||||||
|
|
||||||
|
我们先来看看管程是如何解决互斥问题的。
|
||||||
|
|
||||||
|
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。假如我们要实现一个线程安全的阻塞队列,一个最直观的想法就是:将线程不安全的队列封装起来,对外提供线程安全的操作方法,例如入队操作和出队操作。
|
||||||
|
|
||||||
|
利用管程,可以快速实现这个直观的想法。在下图中,管程X将共享变量queue这个线程不安全的队列和相关的操作入队操作enq()、出队操作deq()都封装起来了;线程A和线程B如果想访问共享变量queue,只能通过调用管程提供的enq()、deq()方法来实现;enq()、deq()保证互斥性,只允许一个线程进入管程。
|
||||||
|
|
||||||
|
不知你有没有发现,管程模型和面向对象高度契合的。估计这也是Java选择管程的原因吧。而我在前面章节介绍的互斥锁用法,其背后的模型其实就是它。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那管程如何解决线程间的同步问题呢?
|
||||||
|
|
||||||
|
这个就比较复杂了,不过你可以借鉴一下我们曾经提到过的就医流程,它可以帮助你快速地理解这个问题。为进一步便于你理解,在下面,我展示了一幅MESA管程模型示意图,它详细描述了MESA模型的主要组成部分。
|
||||||
|
|
||||||
|
在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。
|
||||||
|
|
||||||
|
管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量A和条件变量B分别都有自己的等待队列。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
那条件变量和条件变量等待队列的作用是什么呢?其实就是解决线程同步问题。你可以结合上面提到的阻塞队列的例子加深一下理解(阻塞队列的例子,是用管程来实现线程安全的阻塞队列,这个阻塞队列和管程内部的等待队列没有关系,本文中一定要注意阻塞队列和等待队列是不同的)。
|
||||||
|
|
||||||
|
假设有个线程T1执行阻塞队列的出队操作,执行出队操作,需要注意有个前提条件,就是阻塞队列不能是空的(空队列只能出Null值,是不允许的),阻塞队列不空这个前提条件对应的就是管程里的条件变量。 如果线程T1进入管程后恰好发现阻塞队列是空的,那怎么办呢?等待啊,去哪里等呢?就去条件变量对应的等待队列里面等。此时线程T1就去“队列不空”这个条件变量的等待队列中等待。这个过程类似于大夫发现你要去验个血,于是给你开了个验血的单子,你呢就去验血的队伍里排队。线程T1进入条件变量的等待队列后,是允许其他线程进入管程的。这和你去验血的时候,医生可以给其他患者诊治,道理都是一样的。
|
||||||
|
|
||||||
|
再假设之后另外一个线程T2执行阻塞队列的入队操作,入队操作执行成功之后,“阻塞队列不空”这个条件对于线程T1来说已经满足了,此时线程T2要通知T1,告诉它需要的条件已经满足了。当线程T1得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。这个过程类似你验血完,回来找大夫,需要重新分诊。
|
||||||
|
|
||||||
|
条件变量及其等待队列我们讲清楚了,下面再说说wait()、notify()、notifyAll()这三个操作。前面提到线程T1发现“阻塞队列不空”这个条件不满足,需要进到对应的等待队列里等待。这个过程就是通过调用wait()来实现的。如果我们用对象A代表“阻塞队列不空”这个条件,那么线程T1需要调用A.wait()。同理当“阻塞队列不空”这个条件满足时,线程T2需要调用A.notify()来通知A等待队列中的一个线程,此时这个等待队列里面只有线程T1。至于notifyAll()这个方法,它可以通知等待队列中的所有线程。
|
||||||
|
|
||||||
|
这里我还是来一段代码再次说明一下吧。下面的代码用管程实现了一个线程安全的阻塞队列(再次强调:这个阻塞队列和管程内部的等待队列没关系,示例代码只是用管程来实现阻塞队列,而不是解释管程内部等待队列的实现原理)。阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口。
|
||||||
|
|
||||||
|
|
||||||
|
对于阻塞队列的入队操作,如果阻塞队列已满,就需要等待直到阻塞队列不满,所以这里用了notFull.await();。
|
||||||
|
|
||||||
|
对于阻塞出队操作,如果阻塞队列为空,就需要等待直到阻塞队列不空,所以就用了notEmpty.await();。
|
||||||
|
|
||||||
|
如果入队成功,那么阻塞队列就不空了,就需要通知条件变量:阻塞队列不空notEmpty对应的等待队列。
|
||||||
|
|
||||||
|
如果出队成功,那就阻塞队列就不满了,就需要通知条件变量:阻塞队列不满notFull对应的等待队列。
|
||||||
|
|
||||||
|
public class BlockedQueue{
|
||||||
|
final Lock lock =
|
||||||
|
|
||||||
|
new ReentrantLock();
|
||||||
|
|
||||||
|
// 条件变量:队列不满
|
||||||
|
final Condition notFull =
|
||||||
|
|
||||||
|
lock.newCondition();
|
||||||
|
|
||||||
|
// 条件变量:队列不空
|
||||||
|
final Condition notEmpty =
|
||||||
|
|
||||||
|
lock.newCondition();
|
||||||
|
|
||||||
|
// 入队
|
||||||
|
void enq(T x) {
|
||||||
|
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
while (队列已满){
|
||||||
|
// 等待队列不满
|
||||||
|
notFull.await();
|
||||||
|
}
|
||||||
|
// 省略入队操作...
|
||||||
|
//入队后,通知可出队
|
||||||
|
notEmpty.signal();
|
||||||
|
}finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
// 出队
|
||||||
|
void deq(){
|
||||||
|
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
while (队列已空){
|
||||||
|
// 等待队列不空
|
||||||
|
notEmpty.await();
|
||||||
|
}
|
||||||
|
// 省略出队操作...
|
||||||
|
//出队后,通知可入队
|
||||||
|
notFull.signal();
|
||||||
|
}finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
在这段示例代码中,我们用了Java并发包里面的Lock和Condition,如果你看着吃力,也没关系,后面我们还会详细介绍,这个例子只是先让你明白条件变量及其等待队列是怎么回事。需要注意的是:await()和前面我们提到的wait()语义是一样的;signal()和前面我们提到的notify()语义是一样的。
|
||||||
|
|
||||||
|
wait()的正确姿势
|
||||||
|
|
||||||
|
但是有一点,需要再次提醒,对于MESA管程来说,有一个编程范式,就是需要在一个while循环里面调用wait()。这个是MESA管程特有的。
|
||||||
|
|
||||||
|
while(条件不满足) {
|
||||||
|
wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Hasen模型、Hoare模型和MESA模型的一个核心区别就是当条件满足后,如何通知相关线程。管程要求同一时刻只允许一个线程执行,那当线程T2的操作使线程T1等待的条件满足时,T1和T2究竟谁可以执行呢?
|
||||||
|
|
||||||
|
|
||||||
|
Hasen模型里面,要求notify()放在代码的最后,这样T2通知完T1后,T2就结束了,然后T1再执行,这样就能保证同一时刻只有一个线程执行。
|
||||||
|
Hoare模型里面,T2通知完T1后,T2阻塞,T1马上执行;等T1执行完,再唤醒T2,也能保证同一时刻只有一个线程执行。但是相比Hasen模型,T2多了一次阻塞唤醒操作。
|
||||||
|
MESA管程里面,T2通知完T1后,T2还是会接着执行,T1并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是notify()不用放到代码的最后,T2也没有多余的阻塞唤醒操作。但是也有个副作用,就是当T1再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
|
||||||
|
|
||||||
|
|
||||||
|
notify()何时可以使用
|
||||||
|
|
||||||
|
还有一个需要注意的地方,就是notify()和notifyAll()的使用,前面章节,我曾经介绍过,除非经过深思熟虑,否则尽量使用notifyAll()。那什么时候可以使用notify()呢?需要满足以下三个条件:
|
||||||
|
|
||||||
|
|
||||||
|
所有等待线程拥有相同的等待条件;
|
||||||
|
所有等待线程被唤醒后,执行相同的操作;
|
||||||
|
只需要唤醒一个线程。
|
||||||
|
|
||||||
|
|
||||||
|
比如上面阻塞队列的例子中,对于“阻塞队列不满”这个条件变量,其等待线程都是在等待“阻塞队列不满”这个条件,反映在代码里就是下面这3行代码。对所有等待线程来说,都是执行这3行代码,重点是 while 里面的等待条件是完全相同的。
|
||||||
|
|
||||||
|
while (阻塞队列已满){
|
||||||
|
// 等待队列不满
|
||||||
|
notFull.await();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
所有等待线程被唤醒后执行的操作也是相同的,都是下面这几行:
|
||||||
|
|
||||||
|
// 省略入队操作...
|
||||||
|
// 入队后,通知可出队
|
||||||
|
notEmpty.signal();
|
||||||
|
|
||||||
|
|
||||||
|
同时也满足第3条,只需要唤醒一个线程。所以上面阻塞队列的代码,使用signal()是可以的。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
管程是一个解决并发问题的模型,你可以参考医院就医的流程来加深理解。理解这个模型的重点在于理解条件变量及其等待队列的工作原理。
|
||||||
|
|
||||||
|
Java参考了MESA模型,语言内置的管程(synchronized)对MESA模型进行了精简。MESA模型中,条件变量可以有多个,Java语言内置的管程里只有一个条件变量。具体如下图所示。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Java内置的管程方案(synchronized)使用简单,synchronized关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而Java SDK并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。
|
||||||
|
|
||||||
|
并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的,所以学好管程,就是相当于掌握了一把并发编程的万能钥匙。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
wait()方法,在Hasen模型和Hoare模型里面,都是没有参数的,而在MESA模型里面,增加了超时参数,你觉得这个参数有必要吗?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
176
专栏/Java并发编程实战/09Java线程(上):Java线程的生命周期.md
Normal file
176
专栏/Java并发编程实战/09Java线程(上):Java线程的生命周期.md
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
09 Java线程(上):Java线程的生命周期
|
||||||
|
在Java领域,实现并发程序的主要手段就是多线程。线程是操作系统里的一个概念,虽然各种不同的开发语言如Java、C#等都对其进行了封装,但是万变不离操作系统。Java语言里的线程本质上就是操作系统的线程,它们是一一对应的。
|
||||||
|
|
||||||
|
在操作系统层面,线程也有“生老病死”,专业的说法叫有生命周期。对于有生命周期的事物,要学好它,思路非常简单,只要能搞懂生命周期中各个节点的状态转换机制就可以了。
|
||||||
|
|
||||||
|
虽然不同的开发语言对于操作系统线程进行了不同的封装,但是对于线程的生命周期这部分,基本上是雷同的。所以,我们可以先来了解一下通用的线程生命周期模型,这部分内容也适用于很多其他编程语言;然后再详细有针对性地学习一下Java中线程的生命周期。
|
||||||
|
|
||||||
|
通用的线程生命周期
|
||||||
|
|
||||||
|
通用的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
通用线程状态转换图——五态模型
|
||||||
|
|
||||||
|
这“五态模型”的详细情况如下所示。
|
||||||
|
|
||||||
|
|
||||||
|
初始状态,指的是线程已经被创建,但是还不允许分配CPU执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。
|
||||||
|
可运行状态,指的是线程可以分配CPU执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配CPU执行。
|
||||||
|
当有空闲的CPU时,操作系统会将其分配给一个处于可运行状态的线程,被分配到CPU的线程的状态就转换成了运行状态。
|
||||||
|
运行状态的线程如果调用一个阻塞的API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放CPU使用权,休眠状态的线程永远没有机会获得CPU使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。
|
||||||
|
线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。
|
||||||
|
|
||||||
|
|
||||||
|
这五种状态在不同编程语言里会有简化合并。例如,C语言的POSIX Threads规范,就把初始状态和可运行状态合并了;Java语言里则把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而JVM层面不关心这两个状态,因为JVM把线程调度交给操作系统处理了。
|
||||||
|
|
||||||
|
除了简化合并,这五种状态也有可能被细化,比如,Java语言里就细化了休眠状态(这个下面我们会详细讲解)。
|
||||||
|
|
||||||
|
Java中线程的生命周期
|
||||||
|
|
||||||
|
介绍完通用的线程生命周期模型,想必你已经对线程的“生老病死”有了一个大致的了解。那接下来我们就来详细看看Java语言里的线程生命周期是什么样的。
|
||||||
|
|
||||||
|
Java语言中线程共有六种状态,分别是:
|
||||||
|
|
||||||
|
|
||||||
|
NEW(初始化状态)
|
||||||
|
RUNNABLE(可运行/运行状态)
|
||||||
|
BLOCKED(阻塞状态)
|
||||||
|
WAITING(无时限等待)
|
||||||
|
TIMED_WAITING(有时限等待)
|
||||||
|
TERMINATED(终止状态)
|
||||||
|
|
||||||
|
|
||||||
|
这看上去挺复杂的,状态类型也比较多。但其实在操作系统层面,Java线程中的BLOCKED、WAITING、TIMED_WAITING是一种状态,即前面我们提到的休眠状态。也就是说只要Java线程处于这三种状态之一,那么这个线程就永远没有CPU的使用权。
|
||||||
|
|
||||||
|
所以Java线程的生命周期可以简化为下图:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Java中的线程状态转换图
|
||||||
|
|
||||||
|
其中,BLOCKED、WAITING、TIMED_WAITING可以理解为线程导致休眠状态的三种原因。那具体是哪些情形会导致线程从RUNNABLE状态转换到这三种状态呢?而这三种状态又是何时转换回RUNNABLE的呢?以及NEW、TERMINATED和RUNNABLE状态是如何转换的?
|
||||||
|
|
||||||
|
1. RUNNABLE与BLOCKED的状态转换
|
||||||
|
|
||||||
|
只有一种场景会触发这种转换,就是线程等待synchronized的隐式锁。synchronized修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从RUNNABLE转换到BLOCKED状态。而当等待的线程获得synchronized隐式锁时,就又会从BLOCKED转换到RUNNABLE状态。
|
||||||
|
|
||||||
|
如果你熟悉操作系统线程的生命周期的话,可能会有个疑问:线程调用阻塞式API时,是否会转换到BLOCKED状态呢?在操作系统层面,线程是会转换到休眠状态的,但是在JVM层面,Java线程的状态不会发生变化,也就是说Java线程的状态会依然保持RUNNABLE状态。JVM层面并不关心操作系统调度相关的状态,因为在JVM看来,等待CPU使用权(操作系统层面此时处于可执行状态)与等待I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了RUNNABLE状态。
|
||||||
|
|
||||||
|
而我们平时所谓的Java在调用阻塞式API时,线程会阻塞,指的是操作系统线程的状态,并不是Java线程的状态。
|
||||||
|
|
||||||
|
2. RUNNABLE与WAITING的状态转换
|
||||||
|
|
||||||
|
总体来说,有三种场景会触发这种转换。
|
||||||
|
|
||||||
|
第一种场景,获得synchronized隐式锁的线程,调用无参数的Object.wait()方法。其中,wait()方法我们在上一篇讲解管程的时候已经深入介绍过了,这里就不再赘述。
|
||||||
|
|
||||||
|
第二种场景,调用无参数的Thread.join()方法。其中的join()是一种线程同步方法,例如有一个线程对象thread A,当调用A.join()的时候,执行这条语句的线程会等待thread A执行完,而等待中的这个线程,其状态会从RUNNABLE转换到WAITING。当线程thread A执行完,原来等待它的线程又会从WAITING状态转换到RUNNABLE。
|
||||||
|
|
||||||
|
第三种场景,调用LockSupport.park()方法。其中的LockSupport对象,也许你有点陌生,其实Java并发包中的锁,都是基于它实现的。调用LockSupport.park()方法,当前线程会阻塞,线程的状态会从RUNNABLE转换到WAITING。调用LockSupport.unpark(Thread thread)可唤醒目标线程,目标线程的状态又会从WAITING状态转换到RUNNABLE。
|
||||||
|
|
||||||
|
3. RUNNABLE与TIMED_WAITING的状态转换
|
||||||
|
|
||||||
|
有五种场景会触发这种转换:
|
||||||
|
|
||||||
|
|
||||||
|
调用带超时参数的Thread.sleep(long millis)方法;
|
||||||
|
获得synchronized隐式锁的线程,调用带超时参数的Object.wait(long timeout)方法;
|
||||||
|
调用带超时参数的Thread.join(long millis)方法;
|
||||||
|
调用带超时参数的LockSupport.parkNanos(Object blocker, long deadline)方法;
|
||||||
|
调用带超时参数的LockSupport.parkUntil(long deadline)方法。
|
||||||
|
|
||||||
|
|
||||||
|
这里你会发现TIMED_WAITING和WAITING状态的区别,仅仅是触发条件多了超时参数。
|
||||||
|
|
||||||
|
4. 从NEW到RUNNABLE状态
|
||||||
|
|
||||||
|
Java刚创建出来的Thread对象就是NEW状态,而创建Thread对象主要有两种方法。一种是继承Thread对象,重写run()方法。示例代码如下:
|
||||||
|
|
||||||
|
// 自定义线程对象
|
||||||
|
class MyThread extends Thread {
|
||||||
|
public void run() {
|
||||||
|
// 线程需要执行的代码
|
||||||
|
......
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 创建线程对象
|
||||||
|
MyThread myThread = new MyThread();
|
||||||
|
|
||||||
|
|
||||||
|
另一种是实现Runnable接口,重写run()方法,并将该实现类作为创建Thread对象的参数。示例代码如下:
|
||||||
|
|
||||||
|
// 实现Runnable接口
|
||||||
|
class Runner implements Runnable {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
// 线程需要执行的代码
|
||||||
|
......
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 创建线程对象
|
||||||
|
Thread thread = new Thread(new Runner());
|
||||||
|
|
||||||
|
|
||||||
|
NEW状态的线程,不会被操作系统调度,因此不会执行。Java线程要执行,就必须转换到RUNNABLE状态。从NEW状态转换到RUNNABLE状态很简单,只要调用线程对象的start()方法就可以了,示例代码如下:
|
||||||
|
|
||||||
|
MyThread myThread = new MyThread();
|
||||||
|
// 从NEW状态转换到RUNNABLE状态
|
||||||
|
myThread.start();
|
||||||
|
|
||||||
|
|
||||||
|
5. 从RUNNABLE到TERMINATED状态
|
||||||
|
|
||||||
|
线程执行完 run() 方法后,会自动转换到TERMINATED状态,当然如果执行run()方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断run()方法的执行,例如 run()方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?Java的Thread类里面倒是有个stop()方法,不过已经标记为@Deprecated,所以不建议使用了。正确的姿势其实是调用interrupt()方法。
|
||||||
|
|
||||||
|
那stop()和interrupt()方法的主要区别是什么呢?
|
||||||
|
|
||||||
|
stop()方法会真的杀死线程,不给线程喘息的机会,如果线程持有ReentrantLock锁,被stop()的线程并不会自动调用ReentrantLock的unlock()去释放锁,那其他线程就再也没机会获得ReentrantLock锁,这实在是太危险了。所以该方法就不建议使用了,类似的方法还有suspend() 和 resume()方法,这两个方法同样也都不建议使用了,所以这里也就不多介绍了。
|
||||||
|
|
||||||
|
而interrupt()方法就温柔多了,interrupt()方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。被interrupt的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。
|
||||||
|
|
||||||
|
当线程A处于WAITING、TIMED_WAITING状态时,如果其他线程调用线程A的interrupt()方法,会使线程A返回到RUNNABLE状态,同时线程A的代码会触发InterruptedException异常。上面我们提到转换到WAITING、TIMED_WAITING状态的触发条件,都是调用了类似wait()、join()、sleep()这样的方法,我们看这些方法的签名,发现都会throws InterruptedException这个异常。这个异常的触发条件就是:其他线程调用了该线程的interrupt()方法。
|
||||||
|
|
||||||
|
当线程A处于RUNNABLE状态时,并且阻塞在java.nio.channels.InterruptibleChannel上时,如果其他线程调用线程A的interrupt()方法,线程A会触发java.nio.channels.ClosedByInterruptException这个异常;而阻塞在java.nio.channels.Selector上时,如果其他线程调用线程A的interrupt()方法,线程A的java.nio.channels.Selector会立即返回。
|
||||||
|
|
||||||
|
上面这两种情况属于被中断的线程通过异常的方式获得了通知。还有一种是主动检测,如果线程处于RUNNABLE状态,并且没有阻塞在某个I/O操作上,例如中断计算圆周率的线程A,这时就得依赖线程A主动检测中断状态了。如果其他线程调用线程A的interrupt()方法,那么线程A可以通过isInterrupted()方法,检测是不是自己被中断了。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
理解Java线程的各种状态以及生命周期对于诊断多线程Bug非常有帮助,多线程程序很难调试,出了Bug基本上都是靠日志,靠线程dump来跟踪问题,分析线程dump的一个基本功就是分析线程状态,大部分的死锁、饥饿、活锁问题都需要跟踪分析线程的状态。同时,本文介绍的线程生命周期具备很强的通用性,对于学习其他语言的多线程编程也有很大的帮助。
|
||||||
|
|
||||||
|
你可以通过 jstack 命令或者Java VisualVM这个可视化工具将JVM所有的线程栈信息导出来,完整的线程栈信息不仅包括线程的当前状态、调用栈,还包括了锁的信息。例如,我曾经写过一个死锁的程序,导出的线程栈明确告诉我发生了死锁,并且将死锁线程的调用栈信息清晰地显示出来了(如下图)。导出线程栈,分析线程状态是诊断并发问题的一个重要工具。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
发生死锁的线程栈
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
下面代码的本意是当前线程被中断之后,退出while(true),你觉得这段代码是否正确呢?
|
||||||
|
|
||||||
|
Thread th = Thread.currentThread();
|
||||||
|
while(true) {
|
||||||
|
if(th.isInterrupted()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 省略业务代码无数
|
||||||
|
try {
|
||||||
|
Thread.sleep(100);
|
||||||
|
}catch (InterruptedException e){
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
96
专栏/Java并发编程实战/10Java线程(中):创建多少线程才是合适的?.md
Normal file
96
专栏/Java并发编程实战/10Java线程(中):创建多少线程才是合适的?.md
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
10 Java线程(中):创建多少线程才是合适的?
|
||||||
|
在Java领域,实现并发程序的主要手段就是多线程,使用多线程还是比较简单的,但是使用多少个线程却是个困难的问题。工作中,经常有人问,“各种线程池的线程数量调整成多少是合适的?”或者“Tomcat的线程数、Jdbc连接池的连接数是多少?”等等。那我们应该如何设置合适的线程数呢?
|
||||||
|
|
||||||
|
要解决这个问题,首先要分析以下两个问题:
|
||||||
|
|
||||||
|
|
||||||
|
为什么要使用多线程?
|
||||||
|
多线程的应用场景有哪些?
|
||||||
|
|
||||||
|
|
||||||
|
为什么要使用多线程?
|
||||||
|
|
||||||
|
使用多线程,本质上就是提升程序性能。不过此刻谈到的性能,可能在你脑海里还是比较笼统的,基本上就是快、快、快,这种无法度量的感性认识很不科学,所以在提升性能之前,首要问题是:如何度量性能。
|
||||||
|
|
||||||
|
度量性能的指标有很多,但是有两个指标是最核心的,它们就是延迟和吞吐量。延迟指的是发出请求到收到响应这个过程的时间;延迟越短,意味着程序执行得越快,性能也就越好。 吞吐量指的是在单位时间内能处理请求的数量;吞吐量越大,意味着程序能处理的请求越多,性能也就越好。这两个指标内部有一定的联系(同等条件下,延迟越短,吞吐量越大),但是由于它们隶属不同的维度(一个是时间维度,一个是空间维度),并不能互相转换。
|
||||||
|
|
||||||
|
我们所谓提升性能,从度量的角度,主要是降低延迟,提高吞吐量。这也是我们使用多线程的主要目的。那我们该怎么降低延迟,提高吞吐量呢?这个就要从多线程的应用场景说起了。
|
||||||
|
|
||||||
|
多线程的应用场景
|
||||||
|
|
||||||
|
要想“降低延迟,提高吞吐量”,对应的方法呢,基本上有两个方向,一个方向是优化算法,另一个方向是将硬件的性能发挥到极致。前者属于算法范畴,后者则是和并发编程息息相关了。那计算机主要有哪些硬件呢?主要是两类:一个是I/O,一个是CPU。简言之,在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升I/O的利用率和CPU的利用率。
|
||||||
|
|
||||||
|
估计这个时候你会有个疑问,操作系统不是已经解决了硬件的利用率问题了吗?的确是这样,例如操作系统已经解决了磁盘和网卡的利用率问题,利用中断机制还能避免CPU轮询I/O状态,也提升了CPU的利用率。但是操作系统解决硬件利用率问题的对象往往是单一的硬件设备,而我们的并发程序,往往需要CPU和I/O设备相互配合工作,也就是说,我们需要解决CPU和I/O设备综合利用率的问题。关于这个综合利用率的问题,操作系统虽然没有办法完美解决,但是却给我们提供了方案,那就是:多线程。
|
||||||
|
|
||||||
|
下面我们用一个简单的示例来说明:如何利用多线程来提升CPU和I/O设备的利用率?假设程序按照CPU计算和I/O操作交叉执行的方式运行,而且CPU计算和I/O操作的耗时是1:1。
|
||||||
|
|
||||||
|
如下图所示,如果只有一个线程,执行CPU计算的时候,I/O设备空闲;执行I/O操作的时候,CPU空闲,所以CPU的利用率和I/O设备的利用率都是50%。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
单线程执行示意图
|
||||||
|
|
||||||
|
如果有两个线程,如下图所示,当线程A执行CPU计算的时候,线程B执行I/O操作;当线程A执行I/O操作的时候,线程B执行CPU计算,这样CPU的利用率和I/O设备的利用率就都达到了100%。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
二线程执行示意图
|
||||||
|
|
||||||
|
我们将CPU的利用率和I/O设备的利用率都提升到了100%,会对性能产生了哪些影响呢?通过上面的图示,很容易看出:单位时间处理的请求数量翻了一番,也就是说吞吐量提高了1倍。此时可以逆向思维一下,如果CPU和I/O设备的利用率都很低,那么可以尝试通过增加线程来提高吞吐量。
|
||||||
|
|
||||||
|
在单核时代,多线程主要就是用来平衡CPU和I/O设备的。如果程序只有CPU计算,而没有I/O操作的话,多线程不但不会提升性能,还会使性能变得更差,原因是增加了线程切换的成本。但是在多核时代,这种纯计算型的程序也可以利用多线程来提升性能。为什么呢?因为利用多核可以降低响应时间。
|
||||||
|
|
||||||
|
为便于你理解,这里我举个简单的例子说明一下:计算1+2+… … +100亿的值,如果在4核的CPU上利用4个线程执行,线程A计算[1,25亿),线程B计算[25亿,50亿),线程C计算[50,75亿),线程D计算[75亿,100亿],之后汇总,那么理论上应该比一个线程计算[1,100亿]快将近4倍,响应时间能够降到25%。一个线程,对于4核的CPU,CPU的利用率只有25%,而4个线程,则能够将CPU的利用率提高到100%。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
多核执行多线程示意图
|
||||||
|
|
||||||
|
创建多少线程合适?
|
||||||
|
|
||||||
|
创建多少线程合适,要看多线程具体的应用场景。我们的程序一般都是CPU计算和I/O操作交叉执行的,由于I/O设备的速度相对于CPU来说都很慢,所以大部分情况下,I/O操作执行的时间相对于CPU计算来说都非常长,这种场景我们一般都称为I/O密集型计算;和I/O密集型计算相对的就是CPU密集型计算了,CPU密集型计算大部分场景下都是纯CPU计算。I/O密集型程序和CPU密集型程序,计算最佳线程数的方法是不同的。
|
||||||
|
|
||||||
|
下面我们对这两个场景分别说明。
|
||||||
|
|
||||||
|
对于CPU密集型计算,多线程本质上是提升多核CPU的利用率,所以对于一个4核的CPU,每个核一个线程,理论上创建4个线程就可以了,再多创建线程也只是增加线程切换的成本。所以,对于CPU密集型的计算场景,理论上“线程的数量=CPU核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU核数+1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证CPU的利用率。
|
||||||
|
|
||||||
|
对于I/O密集型的计算场景,比如前面我们的例子中,如果CPU计算和I/O操作的耗时是1:1,那么2个线程是最合适的。如果CPU计算和I/O操作的耗时是1:2,那多少个线程合适呢?是3个线程,如下图所示:CPU在A、B、C三个线程之间切换,对于线程A,当CPU从B、C切换回来时,线程A正好执行完I/O操作。这样CPU和I/O设备的利用率都达到了100%。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
三线程执行示意图
|
||||||
|
|
||||||
|
通过上面这个例子,我们会发现,对于I/O密集型计算场景,最佳的线程数是与程序中CPU计算和I/O操作的耗时比相关的,我们可以总结出这样一个公式:
|
||||||
|
|
||||||
|
|
||||||
|
最佳线程数=1 +(I/O耗时 / CPU耗时)
|
||||||
|
|
||||||
|
|
||||||
|
我们令R=I/O耗时 / CPU耗时,综合上图,可以这样理解:当线程A执行IO操作时,另外R个线程正好执行完各自的CPU计算。这样CPU的利用率就达到了100%。
|
||||||
|
|
||||||
|
不过上面这个公式是针对单核CPU的,至于多核CPU,也很简单,只需要等比扩大就可以了,计算公式如下:
|
||||||
|
|
||||||
|
|
||||||
|
最佳线程数=CPU核数 * [ 1 +(I/O耗时 / CPU耗时)]
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
很多人都知道线程数不是越多越好,但是设置多少是合适的,却又拿不定主意。其实只要把握住一条原则就可以了,这条原则就是将硬件的性能发挥到极致。上面我们针对CPU密集型和I/O密集型计算场景都给出了理论上的最佳公式,这些公式背后的目标其实就是将硬件的性能发挥到极致。
|
||||||
|
|
||||||
|
对于I/O密集型计算场景,I/O耗时和CPU耗时的比值是一个关键参数,不幸的是这个参数是未知的,而且是动态变化的,所以工程上,我们要估算这个参数,然后做各种不同场景下的压测来验证我们的估计。不过工程上,原则还是将硬件的性能发挥到极致,所以压测时,我们需要重点关注CPU、I/O设备的利用率和性能指标(响应时间、吞吐量)之间的关系。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
有些同学对于最佳线程数的设置积累了一些经验值,认为对于I/O密集型应用,最佳线程数应该为:2 * CPU的核数 + 1,你觉得这个经验值合理吗?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
98
专栏/Java并发编程实战/11Java线程(下):为什么局部变量是线程安全的?.md
Normal file
98
专栏/Java并发编程实战/11Java线程(下):为什么局部变量是线程安全的?.md
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
11 Java线程(下):为什么局部变量是线程安全的?
|
||||||
|
我们一遍一遍重复再重复地讲到,多个线程同时访问共享变量的时候,会导致并发问题。那在Java语言里,是不是所有变量都是共享变量呢?工作中我发现不少同学会给方法里面的局部变量设置同步,显然这些同学并没有把共享变量搞清楚。那Java方法里面的局部变量是否存在并发问题呢?下面我们就先结合一个例子剖析下这个问题。
|
||||||
|
|
||||||
|
比如,下面代码里的 fibonacci() 这个方法,会根据传入的参数 n ,返回 1 到 n 的斐波那契数列,斐波那契数列类似这样: 1、1、2、3、5、8、13、21、34……第1项和第2项是1,从第3项开始,每一项都等于前两项之和。在这个方法里面,有个局部变量:数组 r 用来保存数列的结果,每次计算完一项,都会更新数组 r 对应位置中的值。你可以思考这样一个问题,当多个线程调用 fibonacci() 这个方法的时候,数组 r 是否存在数据竞争(Data Race)呢?
|
||||||
|
|
||||||
|
// 返回斐波那契数列
|
||||||
|
int[] fibonacci(int n) {
|
||||||
|
// 创建结果数组
|
||||||
|
int[] r = new int[n];
|
||||||
|
// 初始化第一、第二个数
|
||||||
|
r[0] = r[1] = 1; // ①
|
||||||
|
// 计算2..n
|
||||||
|
for(int i = 2; i < n; i++) {
|
||||||
|
r[i] = r[i-2] + r[i-1];
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
你自己可以在大脑里模拟一下多个线程调用 fibonacci() 方法的情景,假设多个线程执行到 ① 处,多个线程都要对数组r的第1项和第2项赋值,这里看上去感觉是存在数据竞争的,不过感觉再次欺骗了你。
|
||||||
|
|
||||||
|
其实很多人也是知道局部变量不存在数据竞争的,但是至于原因嘛,就说不清楚了。
|
||||||
|
|
||||||
|
那它背后的原因到底是怎样的呢?要弄清楚这个,你需要一点编译原理的知识。你知道在CPU层面,是没有方法概念的,CPU的眼里,只有一条条的指令。编译程序,负责把高级语言里的方法转换成一条条的指令。所以你可以站在编译器实现者的角度来思考“怎么完成方法到指令的转换”。
|
||||||
|
|
||||||
|
方法是如何被执行的
|
||||||
|
|
||||||
|
高级语言里的普通语句,例如上面的r[i] = r[i-2] + r[i-1];翻译成CPU的指令相对简单,可方法的调用就比较复杂了。例如下面这三行代码:第1行,声明一个int变量a;第2行,调用方法 fibonacci(a);第3行,将b赋值给c。
|
||||||
|
|
||||||
|
int a = 7;
|
||||||
|
int[] b = fibonacci(a);
|
||||||
|
int[] c = b;
|
||||||
|
|
||||||
|
|
||||||
|
当你调用fibonacci(a)的时候,CPU要先找到方法 fibonacci() 的地址,然后跳转到这个地址去执行代码,最后CPU执行完方法 fibonacci() 之后,要能够返回。首先找到调用方法的下一条语句的地址:也就是int[] c=b;的地址,再跳转到这个地址去执行。 你可以参考下面这个图再加深一下理解。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
方法的调用过程
|
||||||
|
|
||||||
|
到这里,方法调用的过程想必你已经清楚了,但是还有一个很重要的问题,“CPU去哪里找到调用方法的参数和返回地址?”如果你熟悉CPU的工作原理,你应该会立刻想到:通过CPU的堆栈寄存器。CPU支持一种栈结构,栈你一定很熟悉了,就像手枪的弹夹,先入后出。因为这个栈是和方法调用相关的,因此经常被称为调用栈。
|
||||||
|
|
||||||
|
例如,有三个方法A、B、C,他们的调用关系是A->B->C(A调用B,B调用C),在运行时,会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间,称为栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,栈帧和方法是同生共死的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
调用栈结构
|
||||||
|
|
||||||
|
利用栈结构来支持方法调用这个方案非常普遍,以至于CPU里内置了栈寄存器。虽然各家编程语言定义的方法千奇百怪,但是方法的内部执行原理却是出奇的一致:都是靠栈结构解决的。Java语言虽然是靠虚拟机解释执行的,但是方法的调用也是利用栈结构解决的。
|
||||||
|
|
||||||
|
局部变量存哪里?
|
||||||
|
|
||||||
|
我们已经知道了方法间的调用在CPU眼里是怎么执行的,但还有一个关键问题:方法内的局部变量存哪里?
|
||||||
|
|
||||||
|
局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。此时你应该会想到调用栈的栈帧,调用栈的栈帧就是和方法同生共死的,所以局部变量放到调用栈里那儿是相当的合理。事实上,的确是这样的,局部变量就是放到了调用栈里。于是调用栈的结构就变成了下图这样。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
保护局部变量的调用栈结构
|
||||||
|
|
||||||
|
这个结论相信很多人都知道,因为学Java语言的时候,基本所有的教材都会告诉你 new 出来的对象是在堆里,局部变量是在栈里,只不过很多人并不清楚堆和栈的区别,以及为什么要区分堆和栈。现在你应该很清楚了,局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。
|
||||||
|
|
||||||
|
调用栈与线程
|
||||||
|
|
||||||
|
两个线程可以同时用不同的参数调用相同的方法,那调用栈和线程之间是什么关系呢?答案是:每个线程都有自己独立的调用栈。因为如果不是这样,那两个线程就互相干扰了。如下面这幅图所示,线程A、B、C每个线程都有自己独立的调用栈。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
线程与调用栈的关系图
|
||||||
|
|
||||||
|
现在,让我们回过头来再看篇首的问题:Java方法里面的局部变量是否存在并发问题?现在你应该很清楚了,一点问题都没有。因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。再次重申一遍:没有共享,就没有伤害。
|
||||||
|
|
||||||
|
线程封闭
|
||||||
|
|
||||||
|
方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做线程封闭,比较官方的解释是:仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。
|
||||||
|
|
||||||
|
采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接Connection,在JDBC规范里并没有要求这个Connection必须是线程安全的。数据库连接池通过线程封闭技术,保证一个Connection一旦被一个线程获取之后,在这个线程关闭Connection之前的这段时间里,不会再分配给其他线程,从而保证了Connection不会有并发问题。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
调用栈是一个通用的计算机概念,所有的编程语言都会涉及到,Java调用栈相关的知识,我并没有花费很大的力气去深究,但是靠着那点C语言的知识,稍微思考一下,基本上也就推断出来了。工作了十几年,我发现最近几年和前些年最大的区别是:很多技术的实现原理我都是靠推断,然后看源码验证,而不是像以前一样纯粹靠看源码来总结了。
|
||||||
|
|
||||||
|
建议你也多研究原理性的东西、通用的东西,有这些东西之后再学具体的技术就快多了。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
常听人说,递归调用太深,可能导致栈溢出。你思考一下原因是什么?有哪些解决方案呢?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
132
专栏/Java并发编程实战/12如何用面向对象思想写好并发程序?.md
Normal file
132
专栏/Java并发编程实战/12如何用面向对象思想写好并发程序?.md
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
12 如何用面向对象思想写好并发程序?
|
||||||
|
在工作中,我发现很多同学在设计之初都是直接按照单线程的思路来写程序的,而忽略了本应该重视的并发问题;等上线后的某天,突然发现诡异的Bug,再历经千辛万苦终于定位到问题所在,却发现对于如何解决已经没有了思路。
|
||||||
|
|
||||||
|
关于这个问题,我觉得咱们今天很有必要好好聊聊“如何用面向对象思想写好并发程序”这个话题。
|
||||||
|
|
||||||
|
面向对象思想与并发编程有关系吗?本来是没关系的,它们分属两个不同的领域,但是在Java语言里,这两个领域被无情地融合在一起了,好在融合的效果还是不错的:在Java语言里,面向对象思想能够让并发编程变得更简单。
|
||||||
|
|
||||||
|
那如何才能用面向对象思想写好并发程序呢?结合我自己的工作经验来看,我觉得你可以从封装共享变量、识别共享变量间的约束条件和制定并发访问策略这三个方面下手。
|
||||||
|
|
||||||
|
一、封装共享变量
|
||||||
|
|
||||||
|
并发程序,我们关注的一个核心问题,不过是解决多线程同时访问共享变量的问题。在《03 | 互斥锁(上):解决原子性问题》中,我们类比过球场门票的管理,现实世界里门票管理的一个核心问题是:所有观众只能通过规定的入口进入,否则检票就形同虚设。在编程世界这个问题也很重要,编程领域里面对于共享变量的访问路径就类似于球场的入口,必须严格控制。好在有了面向对象思想,对共享变量的访问路径可以轻松把控。
|
||||||
|
|
||||||
|
面向对象思想里面有一个很重要的特性是封装,封装的通俗解释就是将属性和实现细节封装在对象内部,外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性,这和门票管理模型匹配度相当的高,球场里的座位就是对象属性,球场入口就是对象的公共方法。我们把共享变量作为对象的属性,那对于共享变量的访问路径就是对象的公共方法,所有入口都要安排检票程序就相当于我们前面提到的并发访问策略。
|
||||||
|
|
||||||
|
利用面向对象思想写并发程序的思路,其实就这么简单:将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。就拿很多统计程序都要用到计数器来说,下面的计数器程序共享变量只有一个,就是value,我们把它作为Counter类的属性,并且将两个公共方法get()和addOne()声明为同步方法,这样Counter类就成为一个线程安全的类了。
|
||||||
|
|
||||||
|
public class Counter {
|
||||||
|
private long value;
|
||||||
|
synchronized long get(){
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
synchronized long addOne(){
|
||||||
|
return ++value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
当然,实际工作中,很多的场景都不会像计数器这么简单,经常要面临的情况往往是有很多的共享变量,例如,信用卡账户有卡号、姓名、身份证、信用额度、已出账单、未出账单等很多共享变量。这么多的共享变量,如果每一个都考虑它的并发安全问题,那我们就累死了。但其实仔细观察,你会发现,很多共享变量的值是不会变的,例如信用卡账户的卡号、姓名、身份证。对于这些不会发生变化的共享变量,建议你用final关键字来修饰。这样既能避免并发问题,也能很明了地表明你的设计意图,让后面接手你程序的兄弟知道,你已经考虑过这些共享变量的并发安全问题了。
|
||||||
|
|
||||||
|
二、识别共享变量间的约束条件
|
||||||
|
|
||||||
|
识别共享变量间的约束条件非常重要。因为这些约束条件,决定了并发访问策略。例如,库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限。关于这些约束条件,我们可以用下面的程序来模拟一下。在类SafeWM中,声明了两个成员变量upper和lower,分别代表库存上限和库存下限,这两个变量用了AtomicLong这个原子类,原子类是线程安全的,所以这两个成员变量的set方法就不需要同步了。
|
||||||
|
|
||||||
|
public class SafeWM {
|
||||||
|
// 库存上限
|
||||||
|
private final AtomicLong upper =
|
||||||
|
new AtomicLong(0);
|
||||||
|
// 库存下限
|
||||||
|
private final AtomicLong lower =
|
||||||
|
new AtomicLong(0);
|
||||||
|
// 设置库存上限
|
||||||
|
void setUpper(long v){
|
||||||
|
upper.set(v);
|
||||||
|
}
|
||||||
|
// 设置库存下限
|
||||||
|
void setLower(long v){
|
||||||
|
lower.set(v);
|
||||||
|
}
|
||||||
|
// 省略其他业务代码
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
虽说上面的代码是没有问题的,但是忽视了一个约束条件,就是库存下限要小于库存上限,这个约束条件能够直接加到上面的set方法上吗?我们先直接加一下看看效果(如下面代码所示)。我们在setUpper()和setLower()中增加了参数校验,这乍看上去好像是对的,但其实存在并发问题,问题在于存在竞态条件。这里我顺便插一句,其实当你看到代码里出现if语句的时候,就应该立刻意识到可能存在竞态条件。
|
||||||
|
|
||||||
|
我们假设库存的下限和上限分别是(2,10),线程A调用setUpper(5)将上限设置为5,线程B调用setLower(7)将下限设置为7,如果线程A和线程B完全同时执行,你会发现线程A能够通过参数校验,因为这个时候,下限还没有被线程B设置,还是2,而5>2;线程B也能够通过参数校验,因为这个时候,上限还没有被线程A设置,还是10,而7库存下限要小于库存上限这个约束条件的。
|
||||||
|
|
||||||
|
public class SafeWM {
|
||||||
|
// 库存上限
|
||||||
|
private final AtomicLong upper =
|
||||||
|
new AtomicLong(0);
|
||||||
|
// 库存下限
|
||||||
|
private final AtomicLong lower =
|
||||||
|
new AtomicLong(0);
|
||||||
|
// 设置库存上限
|
||||||
|
void setUpper(long v){
|
||||||
|
// 检查参数合法性
|
||||||
|
if (v < lower.get()) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
upper.set(v);
|
||||||
|
}
|
||||||
|
// 设置库存下限
|
||||||
|
void setLower(long v){
|
||||||
|
// 检查参数合法性
|
||||||
|
if (v > upper.get()) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
lower.set(v);
|
||||||
|
}
|
||||||
|
// 省略其他业务代码
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
在没有识别出库存下限要小于库存上限这个约束条件之前,我们制定的并发访问策略是利用原子类,但是这个策略,完全不能保证库存下限要小于库存上限这个约束条件。所以说,在设计阶段,我们一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙。
|
||||||
|
|
||||||
|
共享变量之间的约束条件,反映在代码里,基本上都会有if语句,所以,一定要特别注意竞态条件。
|
||||||
|
|
||||||
|
三、制定并发访问策略
|
||||||
|
|
||||||
|
制定并发访问策略,是一个非常复杂的事情。应该说整个专栏都是在尝试搞定它。不过从方案上来看,无外乎就是以下“三件事”。
|
||||||
|
|
||||||
|
|
||||||
|
避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
|
||||||
|
不变模式:这个在Java领域应用的很少,但在其他领域却有着广泛的应用,例如Actor模式、CSP模式以及函数式编程的基础都是不变模式。
|
||||||
|
管程及其他同步工具:Java领域万能的解决方案是管程,但是对于很多特定场景,使用Java并发包提供的读写锁、并发容器等同步工具会更好。
|
||||||
|
|
||||||
|
|
||||||
|
接下来在咱们专栏的第二模块我会仔细讲解Java并发工具类以及他们的应用场景,在第三模块我还会讲解并发编程的设计模式,这些都是和制定并发访问策略有关的。
|
||||||
|
|
||||||
|
除了这些方案之外,还有一些宏观的原则需要你了解。这些宏观原则,有助于你写出“健壮”的并发程序。这些原则主要有以下三条。
|
||||||
|
|
||||||
|
|
||||||
|
优先使用成熟的工具类:Java SDK并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便就能发明成功的。
|
||||||
|
迫不得已时才使用低级的同步原语:低级的同步原语主要指的是synchronized、Lock、Semaphore等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
|
||||||
|
避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的。
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
利用面向对象思想编写并发程序,一个关键点就是利用面向对象里的封装特性,由于篇幅原因,这里我只做了简单介绍,详细的你可以借助相关资料定向学习。而对共享变量进行封装,要避免“逸出”,所谓“逸出”简单讲就是共享变量逃逸到对象的外面,比如在《02 | Java内存模型:看Java如何解决可见性和有序性问题》那一篇我们已经讲过构造函数里的this“逸出”。这些都是必须要避免的。
|
||||||
|
|
||||||
|
这是我们专栏并发理论基础的最后一部分内容,这一部分内容主要是让你对并发编程有一个全面的认识,让你了解并发编程里的各种概念,以及它们之间的关系,当然终极目标是让你知道遇到并发问题该怎么思考。这部分的内容还是有点烧脑的,但专栏后面几个模块的内容都是具体的实践部分,相对来说就容易多了。我们一起坚持吧!
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
本期示例代码中,类SafeWM不满足库存下限要小于库存上限这个约束条件,那你来试试修改一下,让它能够在并发条件下满足库存下限要小于库存上限这个约束条件。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
延伸阅读
|
||||||
|
|
||||||
|
关于这部分的内容,如果你觉得还不“过瘾”,这里我再给你推荐一本书吧——《Java并发编程实战》,这本书的第三章《对象的共享》、第四章《对象的组合》全面地介绍了如何构建线程安全的对象,你可以拿来深入地学习。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
215
专栏/Java并发编程实战/13理论基础模块热点问题答疑.md
Normal file
215
专栏/Java并发编程实战/13理论基础模块热点问题答疑.md
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
13 理论基础模块热点问题答疑
|
||||||
|
到这里,专栏的第一模块——并发编程的理论基础,我们已经讲解完了,总共12篇,不算少,但“跳出来,看全景”你会发现这12篇的内容基本上是一个“串行的故事”。所以,在学习过程中,建议你从一个个单一的知识和技术中“跳出来”,看全局,搭建自己的并发编程知识体系。
|
||||||
|
|
||||||
|
为了便于你更好地学习和理解,下面我会先将这些知识点再简单地为你“串”一下,咱们一起复习下;然后就每篇文章的课后思考题、留言区的热门评论,我也集中总结和回复一下。
|
||||||
|
|
||||||
|
那这个“串行的故事”是怎样的呢?
|
||||||
|
|
||||||
|
起源是一个硬件的核心矛盾:CPU与内存、I/O的速度差异,系统软件(操作系统、编译器)在解决这个核心矛盾的同时,引入了可见性、原子性和有序性问题,这三个问题就是很多并发程序的Bug之源。这,就是01的内容。
|
||||||
|
|
||||||
|
那如何解决这三个问题呢?Java语言自然有招儿,它提供了Java内存模型和互斥锁方案。所以,在02我们介绍了Java内存模型,以应对可见性和有序性问题;那另一个原子性问题该如何解决?多方考量用好互斥锁才是关键,这就是03和04的内容。
|
||||||
|
|
||||||
|
虽说互斥锁是解决并发问题的核心工具,但它也可能会带来死锁问题,所以05就介绍了死锁的产生原因以及解决方案;同时还引出一个线程间协作的问题,这也就引出了06这篇文章的内容,介绍线程间的协作机制:等待-通知。
|
||||||
|
|
||||||
|
你应该也看出来了,前六篇文章,我们更多地是站在微观的角度看待并发问题。而07则是换一个角度,站在宏观的角度重新审视并发编程相关的概念和理论,同时也是对前六篇文章的查漏补缺。
|
||||||
|
|
||||||
|
08介绍的管程,是Java并发编程技术的基础,是解决并发问题的万能钥匙。并发编程里两大核心问题——互斥和同步,都是可以由管程来解决的。所以,学好管程,就相当于掌握了一把并发编程的万能钥匙。
|
||||||
|
|
||||||
|
至此,并发编程相关的问题,理论上你都应该能找到问题所在,并能给出理论上的解决方案了。
|
||||||
|
|
||||||
|
而后在09、10和11我们又介绍了线程相关的知识,毕竟Java并发编程是要靠多线程来实现的,所以有针对性地学习这部分知识也是很有必要的,包括线程的生命周期、如何计算合适的线程数以及线程内部是如何执行的。
|
||||||
|
|
||||||
|
最后,在12我们还介绍了如何用面向对象思想写好并发程序,因为在Java语言里,面向对象思想能够让并发编程变得更简单。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
并发编程理论基础模块思维导图
|
||||||
|
|
||||||
|
经过这样一个简要的总结,相信你此时对于并发编程相关的概念、理论、产生的背景以及它们背后的关系已经都有了一个相对全面的认识。至于更深刻的认识和应用体验,还是需要你“钻进去,看本质”,加深对技术本身的认识,拓展知识深度和广度。
|
||||||
|
|
||||||
|
另外,在每篇文章的最后,我都附上了一个思考题,这些思考题虽然大部分都很简单,但是隐藏的问题却很容易让人忽略,从而不经意间就引发了Bug;再加上留言区的一些热门评论,所以我想着将这些隐藏的问题或者易混淆的问题,做一个总结也是很有必要的。
|
||||||
|
|
||||||
|
|
||||||
|
用锁的最佳实践
|
||||||
|
———–
|
||||||
|
|
||||||
|
|
||||||
|
例如,在《03 | 互斥锁(上):解决原子性问题》和《04 | 互斥锁(下):如何用一把锁保护多个资源?》这两篇文章中,我们的思考题都是关于如何创建正确的锁,而思考题里的做法都是错误的。
|
||||||
|
|
||||||
|
03的思考题的示例代码如下,synchronized (new Object()) 这行代码很多同学已经分析出来了,每次调用方法get()、addOne()都创建了不同的锁,相当于无锁。这里需要你再次加深一下记忆,“一个合理的受保护资源与锁之间的关联关系应该是N:1”。只有共享一把锁才能起到互斥的作用。
|
||||||
|
|
||||||
|
另外,很多同学也提到,JVM开启逃逸分析之后,synchronized (new Object()) 这行代码在实际执行的时候会被优化掉,也就是说在真实执行的时候,这行代码压根就不存在。不过无论你是否懂“逃逸分析”都不影响你学好并发编程,如果你对“逃逸分析”感兴趣,可以参考一些JVM相关的资料。
|
||||||
|
|
||||||
|
class SafeCalc {
|
||||||
|
long value = 0L;
|
||||||
|
long get() {
|
||||||
|
synchronized (new Object()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void addOne() {
|
||||||
|
synchronized (new Object()) {
|
||||||
|
value += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
04的思考题转换成代码,是下面这个样子。它的核心问题有两点:一个是锁有可能会变化,另一个是 Integer 和 String 类型的对象不适合做锁。如果锁发生变化,就意味着失去了互斥功能。 Integer 和 String 类型的对象在JVM里面是可能被重用的,除此之外,JVM里可能被重用的对象还有Boolean,那重用意味着什么呢?意味着你的锁可能被其他代码使用,如果其他代码 synchronized(你的锁),而且不释放,那你的程序就永远拿不到锁,这是隐藏的风险。
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
// 账户余额
|
||||||
|
private Integer balance;
|
||||||
|
// 账户密码
|
||||||
|
private String password;
|
||||||
|
// 取款
|
||||||
|
void withdraw(Integer amt) {
|
||||||
|
synchronized(balance) {
|
||||||
|
if (this.balance > amt){
|
||||||
|
this.balance -= amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 更改密码
|
||||||
|
void updatePassword(String pw){
|
||||||
|
synchronized(password) {
|
||||||
|
this.password = pw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
通过这两个反例,我们可以总结出这样一个基本的原则:锁,应是私有的、不可变的、不可重用的。我们经常看到别人家的锁,都长成下面示例代码这样,这种写法貌不惊人,却能避免各种意想不到的坑,这个其实就是最佳实践。最佳实践这方面的资料推荐你看《Java安全编码标准》这本书,研读里面的每一条规则都会让你受益匪浅。
|
||||||
|
|
||||||
|
// 普通对象锁
|
||||||
|
private final Object
|
||||||
|
lock = new Object();
|
||||||
|
// 静态对象锁
|
||||||
|
private static final Object
|
||||||
|
lock = new Object();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
锁的性能要看场景
|
||||||
|
————
|
||||||
|
|
||||||
|
|
||||||
|
《05 | 一不小心就死锁了,怎么办?》的思考题是比较while(!actr.apply(this, target));这个方法和synchronized(Account.class)的性能哪个更好。
|
||||||
|
|
||||||
|
这个要看具体的应用场景,不同应用场景它们的性能表现是不同的。在这个思考题里面,如果转账操作非常费时,那么前者的性能优势就显示出来了,因为前者允许A->B、C->D这种转账业务的并行。不同的并发场景用不同的方案,这是并发编程里面的一项基本原则;没有通吃的技术和方案,因为每种技术和方案都是优缺点和适用场景的。
|
||||||
|
|
||||||
|
|
||||||
|
竞态条件需要格外关注
|
||||||
|
————–
|
||||||
|
|
||||||
|
|
||||||
|
《07 | 安全性、活跃性以及性能问题》里的思考题是一种典型的竞态条件问题(如下所示)。竞态条件问题非常容易被忽略,contains()和add()方法虽然都是线程安全的,但是组合在一起却不是线程安全的。所以你的程序里如果存在类似的组合操作,一定要小心。
|
||||||
|
|
||||||
|
void addIfNotExist(Vector v,
|
||||||
|
Object o){
|
||||||
|
if(!v.contains(o)) {
|
||||||
|
v.add(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这道思考题的解决方法,可以参考《12 | 如何用面向对象思想写好并发程序?》,你需要将共享变量v封装在对象的内部,而后控制并发访问的路径,这样就能有效防止对Vector v变量的滥用,从而导致并发问题。你可以参考下面的示例代码来加深理解。
|
||||||
|
|
||||||
|
class SafeVector{
|
||||||
|
private Vector v;
|
||||||
|
// 所有公共方法增加同步控制
|
||||||
|
synchronized
|
||||||
|
void addIfNotExist(Object o){
|
||||||
|
if(!v.contains(o)) {
|
||||||
|
v.add(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
方法调用是先计算参数
|
||||||
|
————–
|
||||||
|
|
||||||
|
|
||||||
|
不过,还有同学对07文中所举的例子有疑议,认为set(get()+1);这条语句是进入set()方法之后才执行get()方法,其实并不是这样的。方法的调用,是先计算参数,然后将参数压入调用栈之后才会执行方法体,方法调用的过程在11这篇文章中我们已经做了详细的介绍,你可以再次重温一下。
|
||||||
|
|
||||||
|
while(idx++ < 10000) {
|
||||||
|
set(get()+1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
先计算参数这个事情也是容易被忽视的细节。例如,下面写日志的代码,如果日志级别设置为INFO,虽然这行代码不会写日志,但是会计算"The var1:" + var1 + ", var2:" + var2的值,因为方法调用前会先计算参数。
|
||||||
|
|
||||||
|
logger.debug("The var1:" +
|
||||||
|
var1 + ", var2:" + var2);
|
||||||
|
|
||||||
|
|
||||||
|
更好地写法应该是下面这样,这种写法仅仅是讲参数压栈,而没有参数的计算。使用{}占位符是写日志的一个良好习惯。
|
||||||
|
|
||||||
|
logger.debug("The var1:{}, var2:{}",
|
||||||
|
var1, var2);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
InterruptedException异常处理需小心
|
||||||
|
——————————-
|
||||||
|
|
||||||
|
|
||||||
|
《 09 | Java线程(上):Java线程的生命周期》的思考题主要是希望你能够注意InterruptedException的处理方式。当你调用Java对象的wait()方法或者线程的sleep()方法时,需要捕获并处理InterruptedException异常,在思考题里面(如下所示),本意是通过isInterrupted()检查线程是否被中断了,如果中断了就退出while循环。当其他线程通过调用th.interrupt().来中断th线程时,会设置th线程的中断标志位,从而使th.isInterrupted()返回true,这样就能退出while循环了。
|
||||||
|
|
||||||
|
Thread th = Thread.currentThread();
|
||||||
|
while(true) {
|
||||||
|
if(th.isInterrupted()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 省略业务代码无数
|
||||||
|
try {
|
||||||
|
Thread.sleep(100);
|
||||||
|
}catch (InterruptedException e){
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这看上去一点问题没有,实际上却是几乎起不了作用。原因是这段代码在执行的时候,大部分时间都是阻塞在sleep(100)上,当其他线程通过调用th.interrupt().来中断th线程时,大概率地会触发InterruptedException 异常,在触发InterruptedException 异常的同时,JVM会同时把线程的中断标志位清除,所以这个时候th.isInterrupted()返回的是false。
|
||||||
|
|
||||||
|
正确的处理方式应该是捕获异常之后重新设置中断标志位,也就是下面这样:
|
||||||
|
|
||||||
|
try {
|
||||||
|
Thread.sleep(100);
|
||||||
|
}catch(InterruptedException e){
|
||||||
|
// 重新设置中断标志位
|
||||||
|
th.interrupt();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
理论值 or 经验值
|
||||||
|
————–
|
||||||
|
|
||||||
|
|
||||||
|
《10 | Java线程(中):创建多少线程才是合适的?》的思考题是:经验值为“最佳线程=2 * CPU的核数 + 1”,是否合理?
|
||||||
|
|
||||||
|
从理论上来讲,这个经验值一定是靠不住的。但是经验值对于很多“I/O耗时 / CPU耗时”不太容易确定的系统来说,却是一个很好到初始值。
|
||||||
|
|
||||||
|
我们曾讲到最佳线程数最终还是靠压测来确定的,实际工作中大家面临的系统,“I/O耗时 / CPU耗时”往往都大于1,所以基本上都是在这个初始值的基础上增加。增加的过程中,应关注线程数是如何影响吞吐量和延迟的。一般来讲,随着线程数的增加,吞吐量会增加,延迟也会缓慢增加;但是当线程数增加到一定程度,吞吐量就会开始下降,延迟会迅速增加。这个时候基本上就是线程能够设置的最大值了。
|
||||||
|
|
||||||
|
实际工作中,不同的I/O模型对最佳线程数的影响非常大,例如大名鼎鼎的Nginx用的是非阻塞I/O,采用的是多进程单线程结构,Nginx本来是一个I/O密集型系统,但是最佳进程数设置的却是CPU的核数,完全参考的是CPU密集型的算法。所以,理论我们还是要活学活用。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
这个模块,内容主要聚焦在并发编程相关的理论上,但是思考题则是聚焦在细节上,我们经常说细节决定成败,在并发编程领域尤其如此。理论主要用来给我们提供解决问题的思路和方法,但在具体实践的时候,还必须重点关注每一个细节,哪怕有一个细节没有处理好,都会导致并发问题。这方面推荐你认真阅读《Java安全编码标准》这本书,如果你英文足够好,也可以参考这份文档。
|
||||||
|
|
||||||
|
最后总结一句,学好理论有思路,关注细节定成败。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
198
专栏/Java并发编程实战/14Lock和Condition(上):隐藏在并发包中的管程.md
Normal file
198
专栏/Java并发编程实战/14Lock和Condition(上):隐藏在并发包中的管程.md
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
14 Lock和Condition(上):隐藏在并发包中的管程
|
||||||
|
Java SDK并发包内容很丰富,包罗万象,但是我觉得最核心的还是其对管程的实现。因为理论上利用管程,你几乎可以实现并发包里所有的工具类。在前面《08 | 管程:并发编程的万能钥匙》中我们提到过在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。Java SDK并发包通过Lock和Condition两个接口来实现管程,其中Lock用于解决互斥问题,Condition用于解决同步问题。
|
||||||
|
|
||||||
|
今天我们重点介绍Lock的使用,在介绍Lock的使用之前,有个问题需要你首先思考一下:Java语言本身提供的synchronized也是管程的一种实现,既然Java从语言层面已经实现了管程了,那为什么还要在SDK里提供另外一种实现呢?难道Java标准委员会还能同意“重复造轮子”的方案?很显然它们之间是有巨大区别的。那区别在哪里呢?如果能深入理解这个问题,对你用好Lock帮助很大。下面我们就一起来剖析一下这个问题。
|
||||||
|
|
||||||
|
再造管程的理由
|
||||||
|
|
||||||
|
你也许曾经听到过很多这方面的传说,例如在Java的1.5版本中,synchronized性能不如SDK里面的Lock,但1.6版本之后,synchronized做了很多优化,将性能追了上来,所以1.6之后的版本又有人推荐使用synchronized了。那性能是否可以成为“重复造轮子”的理由呢?显然不能。因为性能问题优化一下就可以了,完全没必要“重复造轮子”。
|
||||||
|
|
||||||
|
到这里,关于这个问题,你是否能够想出一条理由来呢?如果你细心的话,也许能想到一点。那就是我们前面在介绍死锁问题的时候,提出了一个破坏不可抢占条件方案,但是这个方案synchronized没有办法解决。原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。但我们希望的是:
|
||||||
|
|
||||||
|
|
||||||
|
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
|
||||||
|
|
||||||
|
|
||||||
|
如果我们重新设计一把互斥锁去解决这个问题,那该怎么设计呢?我觉得有三种方案。
|
||||||
|
|
||||||
|
|
||||||
|
能够响应中断。synchronized的问题是,持有锁A后,如果尝试获取锁B失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说当我们给阻塞的线程发送中断信号的时候,能够唤醒它,那它就有机会释放曾经持有的锁A。这样就破坏了不可抢占条件了。
|
||||||
|
支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
|
||||||
|
非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
|
||||||
|
|
||||||
|
|
||||||
|
这三种方案可以全面弥补synchronized的问题。到这里相信你应该也能理解了,这三个方案就是“重复造轮子”的主要原因,体现在API上,就是Lock接口的三个方法。详情如下:
|
||||||
|
|
||||||
|
// 支持中断的API
|
||||||
|
void lockInterruptibly()
|
||||||
|
throws InterruptedException;
|
||||||
|
// 支持超时的API
|
||||||
|
boolean tryLock(long time, TimeUnit unit)
|
||||||
|
throws InterruptedException;
|
||||||
|
// 支持非阻塞获取锁的API
|
||||||
|
boolean tryLock();
|
||||||
|
|
||||||
|
|
||||||
|
如何保证可见性
|
||||||
|
|
||||||
|
Java SDK里面Lock的使用,有一个经典的范例,就是try{}finally{},需要重点关注的是在finally里面释放锁。这个范例无需多解释,你看一下下面的代码就明白了。但是有一点需要解释一下,那就是可见性是怎么保证的。你已经知道Java里多线程的可见性是通过Happens-Before规则保证的,而synchronized之所以能够保证可见性,也是因为有一条synchronized相关的规则:synchronized的解锁 Happens-Before 于后续对这个锁的加锁。那Java SDK里面Lock靠什么保证可见性呢?例如在下面的代码中,线程T1对value进行了+=1操作,那后续的线程T2能够看到value的正确结果吗?
|
||||||
|
|
||||||
|
class X {
|
||||||
|
private final Lock rtl =
|
||||||
|
new ReentrantLock();
|
||||||
|
int value;
|
||||||
|
public void addOne() {
|
||||||
|
// 获取锁
|
||||||
|
rtl.lock();
|
||||||
|
try {
|
||||||
|
value+=1;
|
||||||
|
} finally {
|
||||||
|
// 保证锁能释放
|
||||||
|
rtl.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
答案必须是肯定的。Java SDK里面锁的实现非常复杂,这里我就不展开细说了,但是原理还是需要简单介绍一下:它是利用了volatile相关的Happens-Before规则。Java SDK里面的ReentrantLock,内部持有一个volatile 的成员变量state,获取锁的时候,会读写state的值;解锁的时候,也会读写state的值(简化后的代码如下面所示)。也就是说,在执行value+=1之前,程序先读写了一次volatile变量state,在执行value+=1之后,又读写了一次volatile变量state。根据相关的Happens-Before规则:
|
||||||
|
|
||||||
|
|
||||||
|
顺序性规则:对于线程T1,value+=1 Happens-Before 释放锁的操作unlock();
|
||||||
|
|
||||||
|
volatile变量规则:由于state = 1会先读取state,所以线程T1的unlock()操作Happens-Before线程T2的lock()操作;
|
||||||
|
|
||||||
|
传递性规则:线程 T1的value+=1 Happens-Before 线程 T2 的 lock() 操作。
|
||||||
|
|
||||||
|
class SampleLock {
|
||||||
|
volatile int state;
|
||||||
|
// 加锁
|
||||||
|
lock() {
|
||||||
|
|
||||||
|
// 省略代码无数
|
||||||
|
state = 1;
|
||||||
|
|
||||||
|
}
|
||||||
|
// 解锁
|
||||||
|
unlock() {
|
||||||
|
|
||||||
|
// 省略代码无数
|
||||||
|
state = 0;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
所以说,后续线程T2能够看到value的正确结果。如果你觉得理解起来还有点困难,建议你重温一下前面我们讲过的《02 | Java内存模型:看Java如何解决可见性和有序性问题》里面的相关内容。
|
||||||
|
|
||||||
|
什么是可重入锁
|
||||||
|
|
||||||
|
如果你细心观察,会发现我们创建的锁的具体类名是ReentrantLock,这个翻译过来叫可重入锁,这个概念前面我们一直没有介绍过。所谓可重入锁,顾名思义,指的是线程可以重复获取同一把锁。例如下面代码中,当线程T1执行到 ① 处时,已经获取到了锁 rtl ,当在 ① 处调用 get()方法时,会在 ② 再次对锁 rtl 执行加锁操作。此时,如果锁 rtl 是可重入的,那么线程T1可以再次加锁成功;如果锁 rtl 是不可重入的,那么线程T1此时会被阻塞。
|
||||||
|
|
||||||
|
除了可重入锁,可能你还听说过可重入函数,可重入函数怎么理解呢?指的是线程可以重复调用?显然不是,所谓可重入函数,指的是多个线程可以同时调用该函数,每个线程都能得到正确结果;同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。多线程可以同时执行,还支持线程切换,这意味着什么呢?线程安全啊。所以,可重入函数是线程安全的。
|
||||||
|
|
||||||
|
class X {
|
||||||
|
private final Lock rtl =
|
||||||
|
new ReentrantLock();
|
||||||
|
int value;
|
||||||
|
public int get() {
|
||||||
|
// 获取锁
|
||||||
|
rtl.lock(); ②
|
||||||
|
try {
|
||||||
|
return value;
|
||||||
|
} finally {
|
||||||
|
// 保证锁能释放
|
||||||
|
rtl.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public void addOne() {
|
||||||
|
// 获取锁
|
||||||
|
rtl.lock();
|
||||||
|
try {
|
||||||
|
value = 1 + get(); ①
|
||||||
|
} finally {
|
||||||
|
// 保证锁能释放
|
||||||
|
rtl.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
公平锁与非公平锁
|
||||||
|
|
||||||
|
在使用ReentrantLock的时候,你会发现ReentrantLock这个类有两个构造函数,一个是无参构造函数,一个是传入fair参数的构造函数。fair参数代表的是锁的公平策略,如果传入true就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。
|
||||||
|
|
||||||
|
//无参构造函数:默认非公平锁
|
||||||
|
public ReentrantLock() {
|
||||||
|
sync = new NonfairSync();
|
||||||
|
}
|
||||||
|
//根据公平策略参数创建锁
|
||||||
|
public ReentrantLock(boolean fair){
|
||||||
|
sync = fair ? new FairSync()
|
||||||
|
: new NonfairSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
在前面《08 | 管程:并发编程的万能钥匙》中,我们介绍过入口等待队列,锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。
|
||||||
|
|
||||||
|
用锁的最佳实践
|
||||||
|
|
||||||
|
你已经知道,用锁虽然能解决很多并发问题,但是风险也是挺高的。可能会导致死锁,也可能影响性能。这方面有是否有相关的最佳实践呢?有,还很多。但是我觉得最值得推荐的是并发大师Doug Lea《Java并发编程:设计原则与模式》一书中,推荐的三个用锁的最佳实践,它们分别是:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
永远只在更新对象的成员变量时加锁
|
||||||
|
永远只在访问可变的成员变量时加锁
|
||||||
|
永远不在调用其他对象的方法时加锁
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这三条规则,前两条估计你一定会认同,最后一条你可能会觉得过于严苛。但是我还是倾向于你去遵守,因为调用其他对象的方法,实在是太不安全了,也许“其他”方法里面有线程sleep()的调用,也可能会有奇慢无比的I/O操作,这些都会严重影响性能。更可怕的是,“其他”类的方法可能也会加锁,然后双重加锁就可能导致死锁。
|
||||||
|
|
||||||
|
并发问题,本来就难以诊断,所以你一定要让你的代码尽量安全,尽量简单,哪怕有一点可能会出问题,都要努力避免。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Java SDK 并发包里的Lock接口里面的每个方法,你可以感受到,都是经过深思熟虑的。除了支持类似synchronized隐式加锁的lock()方法外,还支持超时、非阻塞、可中断的方式获取锁,这三种方式为我们编写更加安全、健壮的并发程序提供了很大的便利。希望你以后在使用锁的时候,一定要仔细斟酌。
|
||||||
|
|
||||||
|
除了并发大师Doug Lea推荐的三个最佳实践外,你也可以参考一些诸如:减少锁的持有时间、减小锁的粒度等业界广为人知的规则,其实本质上它们都是相通的,不过是在该加锁的地方加锁而已。你可以自己体会,自己总结,最终总结出自己的一套最佳实践来。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
你已经知道 tryLock() 支持非阻塞方式获取锁,下面这段关于转账的程序就使用到了 tryLock(),你来看看,它是否存在死锁问题呢?
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
private int balance;
|
||||||
|
private final Lock lock
|
||||||
|
= new ReentrantLock();
|
||||||
|
// 转账
|
||||||
|
void transfer(Account tar, int amt){
|
||||||
|
while (true) {
|
||||||
|
if(this.lock.tryLock()) {
|
||||||
|
try {
|
||||||
|
if (tar.lock.tryLock()) {
|
||||||
|
try {
|
||||||
|
this.balance -= amt;
|
||||||
|
tar.balance += amt;
|
||||||
|
} finally {
|
||||||
|
tar.lock.unlock();
|
||||||
|
}
|
||||||
|
}//if
|
||||||
|
} finally {
|
||||||
|
this.lock.unlock();
|
||||||
|
}
|
||||||
|
}//if
|
||||||
|
}//while
|
||||||
|
}//transfer
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
191
专栏/Java并发编程实战/15Lock和Condition(下):Dubbo如何用管程实现异步转同步?.md
Normal file
191
专栏/Java并发编程实战/15Lock和Condition(下):Dubbo如何用管程实现异步转同步?.md
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
15 Lock和Condition(下):Dubbo如何用管程实现异步转同步?
|
||||||
|
在上一篇文章中,我们讲到Java SDK并发包里的Lock有别于synchronized隐式锁的三个特性:能够响应中断、支持超时和非阻塞地获取锁。那今天我们接着再来详细聊聊Java SDK并发包里的Condition,Condition实现了管程模型里面的条件变量。
|
||||||
|
|
||||||
|
在《08 | 管程:并发编程的万能钥匙》里我们提到过Java 语言内置的管程里只有一个条件变量,而Lock&Condition实现的管程是支持多个条件变量的,这是二者的一个重要区别。
|
||||||
|
|
||||||
|
在很多并发场景下,支持多个条件变量能够让我们的并发程序可读性更好,实现起来也更容易。例如,实现一个阻塞队列,就需要两个条件变量。
|
||||||
|
|
||||||
|
那如何利用两个条件变量快速实现阻塞队列呢?
|
||||||
|
|
||||||
|
一个阻塞队列,需要两个条件变量,一个是队列不空(空队列不允许出队),另一个是队列不满(队列已满不允许入队),这个例子我们前面在介绍管程的时候详细说过,这里就不再赘述。相关的代码,我这里重新列了出来,你可以温故知新一下。
|
||||||
|
|
||||||
|
public class BlockedQueue<T>{
|
||||||
|
final Lock lock =
|
||||||
|
new ReentrantLock();
|
||||||
|
// 条件变量:队列不满
|
||||||
|
final Condition notFull =
|
||||||
|
lock.newCondition();
|
||||||
|
// 条件变量:队列不空
|
||||||
|
final Condition notEmpty =
|
||||||
|
lock.newCondition();
|
||||||
|
|
||||||
|
// 入队
|
||||||
|
void enq(T x) {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
while (队列已满){
|
||||||
|
// 等待队列不满
|
||||||
|
notFull.await();
|
||||||
|
}
|
||||||
|
// 省略入队操作...
|
||||||
|
//入队后,通知可出队
|
||||||
|
notEmpty.signal();
|
||||||
|
}finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 出队
|
||||||
|
void deq(){
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
while (队列已空){
|
||||||
|
// 等待队列不空
|
||||||
|
notEmpty.await();
|
||||||
|
}
|
||||||
|
// 省略出队操作...
|
||||||
|
//出队后,通知可入队
|
||||||
|
notFull.signal();
|
||||||
|
}finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
不过,这里你需要注意,Lock和Condition实现的管程,线程等待和通知需要调用await()、signal()、signalAll(),它们的语义和wait()、notify()、notifyAll()是相同的。但是不一样的是,Lock&Condition实现的管程里只能使用前面的await()、signal()、signalAll(),而后面的wait()、notify()、notifyAll()只有在synchronized实现的管程里才能使用。如果一不小心在Lock&Condition实现的管程里调用了wait()、notify()、notifyAll(),那程序可就彻底玩儿完了。
|
||||||
|
|
||||||
|
Java SDK并发包里的Lock和Condition不过就是管程的一种实现而已,管程你已经很熟悉了,那Lock和Condition的使用自然是小菜一碟。下面我们就来看看在知名项目Dubbo中,Lock和Condition是怎么用的。不过在开始介绍源码之前,我还先要介绍两个概念:同步和异步。
|
||||||
|
|
||||||
|
同步与异步
|
||||||
|
|
||||||
|
我们平时写的代码,基本都是同步的。但最近几年,异步编程大火。那同步和异步的区别到底是什么呢?通俗点来讲就是调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步。
|
||||||
|
|
||||||
|
比如在下面的代码里,有一个计算圆周率小数点后100万位的方法pai1M(),这个方法可能需要执行俩礼拜,如果调用pai1M()之后,线程一直等着计算结果,等俩礼拜之后结果返回,就可以执行 printf("hello world")了,这个属于同步;如果调用pai1M()之后,线程不用等待计算结果,立刻就可以执行 printf("hello world"),这个就属于异步。
|
||||||
|
|
||||||
|
// 计算圆周率小说点后100万位
|
||||||
|
String pai1M() {
|
||||||
|
//省略代码无数
|
||||||
|
}
|
||||||
|
|
||||||
|
pai1M()
|
||||||
|
printf("hello world")
|
||||||
|
|
||||||
|
|
||||||
|
同步,是Java代码默认的处理方式。如果你想让你的程序支持异步,可以通过下面两种方式来实现:
|
||||||
|
|
||||||
|
|
||||||
|
调用方创建一个子线程,在子线程中执行方法调用,这种调用我们称为异步调用;
|
||||||
|
方法实现的时候,创建一个新的线程执行主要逻辑,主线程直接return,这种方法我们一般称为异步方法。
|
||||||
|
|
||||||
|
|
||||||
|
Dubbo源码分析
|
||||||
|
|
||||||
|
其实在编程领域,异步的场景还是挺多的,比如TCP协议本身就是异步的,我们工作中经常用到的RPC调用,在TCP协议层面,发送完RPC请求后,线程是不会等待RPC的响应结果的。可能你会觉得奇怪,平时工作中的RPC调用大多数都是同步的啊?这是怎么回事呢?
|
||||||
|
|
||||||
|
其实很简单,一定是有人帮你做了异步转同步的事情。例如目前知名的RPC框架Dubbo就给我们做了异步转同步的事情,那它是怎么做的呢?下面我们就来分析一下Dubbo的相关源码。
|
||||||
|
|
||||||
|
对于下面一个简单的RPC调用,默认情况下sayHello()方法,是个同步方法,也就是说,执行service.sayHello(“dubbo”)的时候,线程会停下来等结果。
|
||||||
|
|
||||||
|
DemoService service = 初始化部分省略
|
||||||
|
String message =
|
||||||
|
service.sayHello("dubbo");
|
||||||
|
System.out.println(message);
|
||||||
|
|
||||||
|
|
||||||
|
如果此时你将调用线程dump出来的话,会是下图这个样子,你会发现调用线程阻塞了,线程状态是TIMED_WAITING。本来发送请求是异步的,但是调用线程却阻塞了,说明Dubbo帮我们做了异步转同步的事情。通过调用栈,你能看到线程是阻塞在DefaultFuture.get()方法上,所以可以推断:Dubbo异步转同步的功能应该是通过DefaultFuture这个类实现的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
调用栈信息
|
||||||
|
|
||||||
|
不过为了理清前后关系,还是有必要分析一下调用DefaultFuture.get()之前发生了什么。DubboInvoker的108行调用了DefaultFuture.get(),这一行很关键,我稍微修改了一下列在了下面。这一行先调用了request(inv, timeout)方法,这个方法其实就是发送RPC请求,之后通过调用get()方法等待RPC返回结果。
|
||||||
|
|
||||||
|
public class DubboInvoker{
|
||||||
|
Result doInvoke(Invocation inv){
|
||||||
|
// 下面这行就是源码中108行
|
||||||
|
// 为了便于展示,做了修改
|
||||||
|
return currentClient
|
||||||
|
.request(inv, timeout)
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DefaultFuture这个类是很关键,我把相关的代码精简之后,列到了下面。不过在看代码之前,你还是有必要重复一下我们的需求:当RPC返回结果之前,阻塞调用线程,让调用线程等待;当RPC返回结果后,唤醒调用线程,让调用线程重新执行。不知道你有没有似曾相识的感觉,这不就是经典的等待-通知机制吗?这个时候想必你的脑海里应该能够浮现出管程的解决方案了。有了自己的方案之后,我们再来看看Dubbo是怎么实现的。
|
||||||
|
|
||||||
|
// 创建锁与条件变量
|
||||||
|
private final Lock lock
|
||||||
|
= new ReentrantLock();
|
||||||
|
private final Condition done
|
||||||
|
= lock.newCondition();
|
||||||
|
|
||||||
|
// 调用方通过该方法等待结果
|
||||||
|
Object get(int timeout){
|
||||||
|
long start = System.nanoTime();
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
while (!isDone()) {
|
||||||
|
done.await(timeout);
|
||||||
|
long cur=System.nanoTime();
|
||||||
|
if (isDone() ||
|
||||||
|
cur-start > timeout){
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
if (!isDone()) {
|
||||||
|
throw new TimeoutException();
|
||||||
|
}
|
||||||
|
return returnFromResponse();
|
||||||
|
}
|
||||||
|
// RPC结果是否已经返回
|
||||||
|
boolean isDone() {
|
||||||
|
return response != null;
|
||||||
|
}
|
||||||
|
// RPC结果返回时调用该方法
|
||||||
|
private void doReceived(Response res) {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
response = res;
|
||||||
|
if (done != null) {
|
||||||
|
done.signal();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
调用线程通过调用get()方法等待RPC返回结果,这个方法里面,你看到的都是熟悉的“面孔”:调用lock()获取锁,在finally里面调用unlock()释放锁;获取锁后,通过经典的在循环中调用await()方法来实现等待。
|
||||||
|
|
||||||
|
当RPC结果返回时,会调用doReceived()方法,这个方法里面,调用lock()获取锁,在finally里面调用unlock()释放锁,获取锁后通过调用signal()来通知调用线程,结果已经返回,不用继续等待了。
|
||||||
|
|
||||||
|
至此,Dubbo里面的异步转同步的源码就分析完了,有没有觉得还挺简单的?最近这几年,工作中需要异步处理的越来越多了,其中有一个主要原因就是有些API本身就是异步API。例如websocket也是一个异步的通信协议,如果基于这个协议实现一个简单的RPC,你也会遇到异步转同步的问题。现在很多公有云的API本身也是异步的,例如创建云主机,就是一个异步的API,调用虽然成功了,但是云主机并没有创建成功,你需要调用另外一个API去轮询云主机的状态。如果你需要在项目内部封装创建云主机的API,你也会面临异步转同步的问题,因为同步的API更易用。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Lock&Condition是管程的一种实现,所以能否用好Lock和Condition要看你对管程模型理解得怎么样。管程的技术前面我们已经专门用了一篇文章做了介绍,你可以结合着来学,理论联系实践,有助于加深理解。
|
||||||
|
|
||||||
|
Lock&Condition实现的管程相对于synchronized实现的管程来说更加灵活、功能也更丰富。
|
||||||
|
|
||||||
|
结合我自己的经验,我认为了解原理比了解实现更能让你快速学好并发编程,所以没有介绍太多Java SDK并发包里锁和条件变量是如何实现的。但如果你对实现感兴趣,可以参考《Java并发编程的艺术》一书的第5章《Java中的锁》,里面详细介绍了实现原理,我觉得写得非常好。
|
||||||
|
|
||||||
|
另外,专栏里对DefaultFuture的代码缩减了很多,如果你感兴趣,也可以去看看完整版。-
|
||||||
|
Dubbo的源代码在Github上,DefaultFuture的路径是:incubator-dubbo/dubbo-remoting/dubbo-remoting-api/src/main/java/org/apache/dubbo/remoting/exchange/support/DefaultFuture.java。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
DefaultFuture里面唤醒等待的线程,用的是signal(),而不是signalAll(),你来分析一下,这样做是否合理呢?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
150
专栏/Java并发编程实战/16Semaphore:如何快速实现一个限流器?.md
Normal file
150
专栏/Java并发编程实战/16Semaphore:如何快速实现一个限流器?.md
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
16 Semaphore:如何快速实现一个限流器?
|
||||||
|
Semaphore,现在普遍翻译为“信号量”,以前也曾被翻译成“信号灯”,因为类似现实生活里的红绿灯,车辆能不能通行,要看是不是绿灯。同样,在编程世界里,线程能不能执行,也要看信号量是不是允许。
|
||||||
|
|
||||||
|
信号量是由大名鼎鼎的计算机科学家迪杰斯特拉(Dijkstra)于1965年提出,在这之后的15年,信号量一直都是并发编程领域的终结者,直到1980年管程被提出来,我们才有了第二选择。目前几乎所有支持并发编程的语言都支持信号量机制,所以学好信号量还是很有必要的。
|
||||||
|
|
||||||
|
下面我们首先介绍信号量模型,之后介绍如何使用信号量,最后我们再用信号量来实现一个限流器。
|
||||||
|
|
||||||
|
信号量模型
|
||||||
|
|
||||||
|
信号量模型还是很简单的,可以简单概括为:一个计数器,一个等待队列,三个方法。在信号量模型里,计数器和等待队列对外是透明的,所以只能通过信号量模型提供的三个方法来访问它们,这三个方法分别是:init()、down()和up()。你可以结合下图来形象化地理解。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
信号量模型图
|
||||||
|
|
||||||
|
这三个方法详细的语义具体如下所示。
|
||||||
|
|
||||||
|
|
||||||
|
init():设置计数器的初始值。
|
||||||
|
down():计数器的值减1;如果此时计数器的值小于0,则当前线程将被阻塞,否则当前线程可以继续执行。
|
||||||
|
up():计数器的值加1;如果此时计数器的值小于或者等于0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。
|
||||||
|
|
||||||
|
|
||||||
|
这里提到的init()、down()和up()三个方法都是原子性的,并且这个原子性是由信号量模型的实现方保证的。在Java SDK里面,信号量模型是由java.util.concurrent.Semaphore实现的,Semaphore这个类能够保证这三个方法都是原子操作。
|
||||||
|
|
||||||
|
如果你觉得上面的描述有点绕的话,可以参考下面这个代码化的信号量模型。
|
||||||
|
|
||||||
|
class Semaphore{
|
||||||
|
// 计数器
|
||||||
|
int count;
|
||||||
|
// 等待队列
|
||||||
|
Queue queue;
|
||||||
|
// 初始化操作
|
||||||
|
Semaphore(int c){
|
||||||
|
this.count=c;
|
||||||
|
}
|
||||||
|
//
|
||||||
|
void down(){
|
||||||
|
this.count--;
|
||||||
|
if(this.count<0){
|
||||||
|
//将当前线程插入等待队列
|
||||||
|
//阻塞当前线程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void up(){
|
||||||
|
this.count++;
|
||||||
|
if(this.count<=0) {
|
||||||
|
//移除等待队列中的某个线程T
|
||||||
|
//唤醒线程T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这里再插一句,信号量模型里面,down()、up()这两个操作历史上最早称为P操作和V操作,所以信号量模型也被称为PV原语。另外,还有些人喜欢用semWait()和semSignal()来称呼它们,虽然叫法不同,但是语义都是相同的。在Java SDK并发包里,down()和up()对应的则是acquire()和release()。
|
||||||
|
|
||||||
|
如何使用信号量
|
||||||
|
|
||||||
|
通过上文,你应该会发现信号量的模型还是很简单的,那具体该如何使用呢?其实你想想红绿灯就可以了。十字路口的红绿灯可以控制交通,得益于它的一个关键规则:车辆在通过路口前必须先检查是否是绿灯,只有绿灯才能通行。这个规则和我们前面提到的锁规则是不是很类似?
|
||||||
|
|
||||||
|
其实,信号量的使用也是类似的。这里我们还是用累加器的例子来说明信号量的使用吧。在累加器的例子里面,count+=1操作是个临界区,只允许一个线程执行,也就是说要保证互斥。那这种情况用信号量怎么控制呢?
|
||||||
|
|
||||||
|
其实很简单,就像我们用互斥锁一样,只需要在进入临界区之前执行一下down()操作,退出临界区之前执行一下up()操作就可以了。下面是Java代码的示例,acquire()就是信号量里的down()操作,release()就是信号量里的up()操作。
|
||||||
|
|
||||||
|
static int count;
|
||||||
|
//初始化信号量
|
||||||
|
static final Semaphore s
|
||||||
|
= new Semaphore(1);
|
||||||
|
//用信号量保证互斥
|
||||||
|
static void addOne() {
|
||||||
|
s.acquire();
|
||||||
|
try {
|
||||||
|
count+=1;
|
||||||
|
} finally {
|
||||||
|
s.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
下面我们再来分析一下,信号量是如何保证互斥的。假设两个线程T1和T2同时访问addOne()方法,当它们同时调用acquire()的时候,由于acquire()是一个原子操作,所以只能有一个线程(假设T1)把信号量里的计数器减为0,另外一个线程(T2)则是将计数器减为-1。对于线程T1,信号量里面的计数器的值是0,大于等于0,所以线程T1会继续执行;对于线程T2,信号量里面的计数器的值是-1,小于0,按照信号量模型里对down()操作的描述,线程T2将被阻塞。所以此时只有线程T1会进入临界区执行count+=1;。
|
||||||
|
|
||||||
|
当线程T1执行release()操作,也就是up()操作的时候,信号量里计数器的值是-1,加1之后的值是0,小于等于0,按照信号量模型里对up()操作的描述,此时等待队列中的T2将会被唤醒。于是T2在T1执行完临界区代码之后才获得了进入临界区执行的机会,从而保证了互斥性。
|
||||||
|
|
||||||
|
快速实现一个限流器
|
||||||
|
|
||||||
|
上面的例子,我们用信号量实现了一个最简单的互斥锁功能。估计你会觉得奇怪,既然有Java SDK里面提供了Lock,为啥还要提供一个Semaphore ?其实实现一个互斥锁,仅仅是 Semaphore的部分功能,Semaphore还有一个功能是Lock不容易实现的,那就是:Semaphore可以允许多个线程访问一个临界区。
|
||||||
|
|
||||||
|
现实中还有这种需求?有的。比较常见的需求就是我们工作中遇到的各种池化资源,例如连接池、对象池、线程池等等。其中,你可能最熟悉数据库连接池,在同一时刻,一定是允许多个线程同时使用连接池的,当然,每个连接在被释放前,是不允许其他线程使用的。
|
||||||
|
|
||||||
|
其实前不久,我在工作中也遇到了一个对象池的需求。所谓对象池呢,指的是一次性创建出N个对象,之后所有的线程重复利用这N个对象,当然对象在被释放前,也是不允许其他线程使用的。对象池,可以用List保存实例对象,这个很简单。但关键是限流器的设计,这里的限流,指的是不允许多于N个线程同时进入临界区。那如何快速实现一个这样的限流器呢?这种场景,我立刻就想到了信号量的解决方案。
|
||||||
|
|
||||||
|
信号量的计数器,在上面的例子中,我们设置成了1,这个1表示只允许一个线程进入临界区,但如果我们把计数器的值设置成对象池里对象的个数N,就能完美解决对象池的限流问题了。下面就是对象池的示例代码。
|
||||||
|
|
||||||
|
class ObjPool<T, R> {
|
||||||
|
final List<T> pool;
|
||||||
|
// 用信号量实现限流器
|
||||||
|
final Semaphore sem;
|
||||||
|
// 构造函数
|
||||||
|
ObjPool(int size, T t){
|
||||||
|
pool = new Vector<T>(){};
|
||||||
|
for(int i=0; i<size; i++){
|
||||||
|
pool.add(t);
|
||||||
|
}
|
||||||
|
sem = new Semaphore(size);
|
||||||
|
}
|
||||||
|
// 利用对象池的对象,调用func
|
||||||
|
R exec(Function<T,R> func) {
|
||||||
|
T t = null;
|
||||||
|
sem.acquire();
|
||||||
|
try {
|
||||||
|
t = pool.remove(0);
|
||||||
|
return func.apply(t);
|
||||||
|
} finally {
|
||||||
|
pool.add(t);
|
||||||
|
sem.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 创建对象池
|
||||||
|
ObjPool<Long, String> pool =
|
||||||
|
new ObjPool<Long, String>(10, 2);
|
||||||
|
// 通过对象池获取t,之后执行
|
||||||
|
pool.exec(t -> {
|
||||||
|
System.out.println(t);
|
||||||
|
return t.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
我们用一个List来保存对象实例,用Semaphore实现限流器。关键的代码是ObjPool里面的exec()方法,这个方法里面实现了限流的功能。在这个方法里面,我们首先调用acquire()方法(与之匹配的是在finally里面调用release()方法),假设对象池的大小是10,信号量的计数器初始化为10,那么前10个线程调用acquire()方法,都能继续执行,相当于通过了信号灯,而其他线程则会阻塞在acquire()方法上。对于通过信号灯的线程,我们为每个线程分配了一个对象 t(这个分配工作是通过pool.remove(0)实现的),分配完之后会执行一个回调函数func,而函数的参数正是前面分配的对象 t ;执行完回调函数之后,它们就会释放对象(这个释放工作是通过pool.add(t)实现的),同时调用release()方法来更新信号量的计数器。如果此时信号量里计数器的值小于等于0,那么说明有线程在等待,此时会自动唤醒等待的线程。
|
||||||
|
|
||||||
|
简言之,使用信号量,我们可以轻松地实现一个限流器,使用起来还是非常简单的。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
信号量在Java语言里面名气并不算大,但是在其他语言里却是很有知名度的。Java在并发编程领域走的很快,重点支持的还是管程模型。 管程模型理论上解决了信号量模型的一些不足,主要体现在易用性和工程化方面,例如用信号量解决我们曾经提到过的阻塞队列问题,就比管程模型麻烦很多,你如果感兴趣,可以课下了解和尝试一下。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
在上面对象池的例子中,对象保存在了Vector中,Vector是Java提供的线程安全的容器,如果我们把Vector换成ArrayList,是否可以呢?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
202
专栏/Java并发编程实战/17ReadWriteLock:如何快速实现一个完备的缓存?.md
Normal file
202
专栏/Java并发编程实战/17ReadWriteLock:如何快速实现一个完备的缓存?.md
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
17 ReadWriteLock:如何快速实现一个完备的缓存?
|
||||||
|
前面我们介绍了管程和信号量这两个同步原语在Java语言中的实现,理论上用这两个同步原语中任何一个都可以解决所有的并发问题。那Java SDK并发包里为什么还有很多其他的工具类呢?原因很简单:分场景优化性能,提升易用性。
|
||||||
|
|
||||||
|
今天我们就介绍一种非常普遍的并发场景:读多写少场景。实际工作中,为了优化性能,我们经常会使用缓存,例如缓存元数据、缓存基础数据等,这就是一种典型的读多写少应用场景。缓存之所以能提升性能,一个重要的条件就是缓存的数据一定是读多写少的,例如元数据和基础数据基本上不会发生变化(写少),但是使用它们的地方却很多(读多)。
|
||||||
|
|
||||||
|
针对读多写少这种并发场景,Java SDK并发包提供了读写锁——ReadWriteLock,非常容易使用,并且性能很好。
|
||||||
|
|
||||||
|
那什么是读写锁呢?
|
||||||
|
|
||||||
|
读写锁,并不是Java语言特有的,而是一个广为使用的通用技术,所有的读写锁都遵守以下三条基本原则:
|
||||||
|
|
||||||
|
|
||||||
|
允许多个线程同时读共享变量;
|
||||||
|
只允许一个线程写共享变量;
|
||||||
|
如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
|
||||||
|
|
||||||
|
|
||||||
|
读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作。
|
||||||
|
|
||||||
|
快速实现一个缓存
|
||||||
|
|
||||||
|
下面我们就实践起来,用ReadWriteLock快速实现一个通用的缓存工具类。
|
||||||
|
|
||||||
|
在下面的代码中,我们声明了一个Cache类,其中类型参数K代表缓存里key的类型,V代表缓存里value的类型。缓存的数据保存在Cache类内部的HashMap里面,HashMap不是线程安全的,这里我们使用读写锁ReadWriteLock 来保证其线程安全。ReadWriteLock 是一个接口,它的实现类是ReentrantReadWriteLock,通过名字你应该就能判断出来,它是支持可重入的。下面我们通过rwl创建了一把读锁和一把写锁。
|
||||||
|
|
||||||
|
Cache这个工具类,我们提供了两个方法,一个是读缓存方法get(),另一个是写缓存方法put()。读缓存需要用到读锁,读锁的使用和前面我们介绍的Lock的使用是相同的,都是try{}finally{}这个编程范式。写缓存则需要用到写锁,写锁的使用和读锁是类似的。这样看来,读写锁的使用还是非常简单的。
|
||||||
|
|
||||||
|
class Cache<K,V> {
|
||||||
|
final Map<K, V> m =
|
||||||
|
new HashMap<>();
|
||||||
|
final ReadWriteLock rwl =
|
||||||
|
new ReentrantReadWriteLock();
|
||||||
|
// 读锁
|
||||||
|
final Lock r = rwl.readLock();
|
||||||
|
// 写锁
|
||||||
|
final Lock w = rwl.writeLock();
|
||||||
|
// 读缓存
|
||||||
|
V get(K key) {
|
||||||
|
r.lock();
|
||||||
|
try { return m.get(key); }
|
||||||
|
finally { r.unlock(); }
|
||||||
|
}
|
||||||
|
// 写缓存
|
||||||
|
V put(K key, V value) {
|
||||||
|
w.lock();
|
||||||
|
try { return m.put(key, v); }
|
||||||
|
finally { w.unlock(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
如果你曾经使用过缓存的话,你应该知道使用缓存首先要解决缓存数据的初始化问题。缓存数据的初始化,可以采用一次性加载的方式,也可以使用按需加载的方式。
|
||||||
|
|
||||||
|
如果源头数据的数据量不大,就可以采用一次性加载的方式,这种方式最简单(可参考下图),只需在应用启动的时候把源头数据查询出来,依次调用类似上面示例代码中的put()方法就可以了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
缓存一次性加载示意图
|
||||||
|
|
||||||
|
如果源头数据量非常大,那么就需要按需加载了,按需加载也叫懒加载,指的是只有当应用查询缓存,并且数据不在缓存里的时候,才触发加载源头相关数据进缓存的操作。下面你可以结合文中示意图看看如何利用ReadWriteLock 来实现缓存的按需加载。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
缓存按需加载示意图
|
||||||
|
|
||||||
|
实现缓存的按需加载
|
||||||
|
|
||||||
|
文中下面的这段代码实现了按需加载的功能,这里我们假设缓存的源头是数据库。需要注意的是,如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存需要用到写锁,所以在代码中的⑤处,我们调用了 w.lock() 来获取写锁。
|
||||||
|
|
||||||
|
另外,还需要注意的是,在获取写锁之后,我们并没有直接去查询数据库,而是在代码⑥⑦处,重新验证了一次缓存中是否存在,再次验证如果还是不存在,我们才去查询数据库并更新本地缓存。为什么我们要再次验证呢?
|
||||||
|
|
||||||
|
class Cache<K,V> {
|
||||||
|
final Map<K, V> m =
|
||||||
|
new HashMap<>();
|
||||||
|
final ReadWriteLock rwl =
|
||||||
|
new ReentrantReadWriteLock();
|
||||||
|
final Lock r = rwl.readLock();
|
||||||
|
final Lock w = rwl.writeLock();
|
||||||
|
|
||||||
|
V get(K key) {
|
||||||
|
V v = null;
|
||||||
|
//读缓存
|
||||||
|
r.lock(); ①
|
||||||
|
try {
|
||||||
|
v = m.get(key); ②
|
||||||
|
} finally{
|
||||||
|
r.unlock(); ③
|
||||||
|
}
|
||||||
|
//缓存中存在,返回
|
||||||
|
if(v != null) { ④
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
//缓存中不存在,查询数据库
|
||||||
|
w.lock(); ⑤
|
||||||
|
try {
|
||||||
|
//再次验证
|
||||||
|
//其他线程可能已经查询过数据库
|
||||||
|
v = m.get(key); ⑥
|
||||||
|
if(v == null){ ⑦
|
||||||
|
//查询数据库
|
||||||
|
v=省略代码无数
|
||||||
|
m.put(key, v);
|
||||||
|
}
|
||||||
|
} finally{
|
||||||
|
w.unlock();
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
原因是在高并发的场景下,有可能会有多线程竞争写锁。假设缓存是空的,没有缓存任何东西,如果此时有三个线程T1、T2和T3同时调用get()方法,并且参数key也是相同的。那么它们会同时执行到代码⑤处,但此时只有一个线程能够获得写锁,假设是线程T1,线程T1获取写锁之后查询数据库并更新缓存,最终释放写锁。此时线程T2和T3会再有一个线程能够获取写锁,假设是T2,如果不采用再次验证的方式,此时T2会再次查询数据库。T2释放写锁之后,T3也会再次查询一次数据库。而实际上线程T1已经把缓存的值设置好了,T2、T3完全没有必要再次查询数据库。所以,再次验证的方式,能够避免高并发场景下重复查询数据的问题。
|
||||||
|
|
||||||
|
读写锁的升级与降级
|
||||||
|
|
||||||
|
上面按需加载的示例代码中,在①处获取读锁,在③处释放读锁,那是否可以在②处的下面增加验证缓存并更新缓存的逻辑呢?详细的代码如下。
|
||||||
|
|
||||||
|
//读缓存
|
||||||
|
r.lock(); ①
|
||||||
|
try {
|
||||||
|
v = m.get(key); ②
|
||||||
|
if (v == null) {
|
||||||
|
w.lock();
|
||||||
|
try {
|
||||||
|
//再次验证并更新缓存
|
||||||
|
//省略详细代码
|
||||||
|
} finally{
|
||||||
|
w.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally{
|
||||||
|
r.unlock(); ③
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这样看上去好像是没有问题的,先是获取读锁,然后再升级为写锁,对此还有个专业的名字,叫锁的升级。可惜ReadWriteLock并不支持这种升级。在上面的代码示例中,读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒。锁的升级是不允许的,这个你一定要注意。
|
||||||
|
|
||||||
|
不过,虽然锁的升级是不允许的,但是锁的降级却是允许的。以下代码来源自ReentrantReadWriteLock的官方示例,略做了改动。你会发现在代码①处,获取读锁的时候线程还是持有写锁的,这种锁的降级是支持的。
|
||||||
|
|
||||||
|
class CachedData {
|
||||||
|
Object data;
|
||||||
|
volatile boolean cacheValid;
|
||||||
|
final ReadWriteLock rwl =
|
||||||
|
new ReentrantReadWriteLock();
|
||||||
|
// 读锁
|
||||||
|
final Lock r = rwl.readLock();
|
||||||
|
//写锁
|
||||||
|
final Lock w = rwl.writeLock();
|
||||||
|
|
||||||
|
void processCachedData() {
|
||||||
|
// 获取读锁
|
||||||
|
r.lock();
|
||||||
|
if (!cacheValid) {
|
||||||
|
// 释放读锁,因为不允许读锁的升级
|
||||||
|
r.unlock();
|
||||||
|
// 获取写锁
|
||||||
|
w.lock();
|
||||||
|
try {
|
||||||
|
// 再次检查状态
|
||||||
|
if (!cacheValid) {
|
||||||
|
data = ...
|
||||||
|
cacheValid = true;
|
||||||
|
}
|
||||||
|
// 释放写锁前,降级为读锁
|
||||||
|
// 降级是可以的
|
||||||
|
r.lock(); ①
|
||||||
|
} finally {
|
||||||
|
// 释放写锁
|
||||||
|
w.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 此处仍然持有读锁
|
||||||
|
try {use(data);}
|
||||||
|
finally {r.unlock();}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
读写锁类似于ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock接口,所以除了支持lock()方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。但是有一点需要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用newCondition()会抛出UnsupportedOperationException异常。
|
||||||
|
|
||||||
|
今天我们用ReadWriteLock实现了一个简单的缓存,这个缓存虽然解决了缓存的初始化问题,但是没有解决缓存数据与源头数据的同步问题,这里的数据同步指的是保证缓存数据和源头数据的一致性。解决数据同步问题的一个最简单的方案就是超时机制。所谓超时机制指的是加载进缓存的数据不是长久有效的,而是有时效的,当缓存的数据超过时效,也就是超时之后,这条数据在缓存中就失效了。而访问缓存中失效的数据,会触发缓存重新从源头把数据加载进缓存。
|
||||||
|
|
||||||
|
当然也可以在源头数据发生变化时,快速反馈给缓存,但这个就要依赖具体的场景了。例如MySQL作为数据源头,可以通过近实时地解析binlog来识别数据是否发生了变化,如果发生了变化就将最新的数据推送给缓存。另外,还有一些方案采取的是数据库和缓存的双写方案。
|
||||||
|
|
||||||
|
总之,具体采用哪种方案,还是要看应用的场景。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
有同学反映线上系统停止响应了,CPU利用率很低,你怀疑有同学一不小心写出了读锁升级写锁的方案,那你该如何验证自己的怀疑呢?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
208
专栏/Java并发编程实战/18StampedLock:有没有比读写锁更快的锁?.md
Normal file
208
专栏/Java并发编程实战/18StampedLock:有没有比读写锁更快的锁?.md
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
18 StampedLock:有没有比读写锁更快的锁?
|
||||||
|
在上一篇文章中,我们介绍了读写锁,学习完之后你应该已经知道“读写锁允许多个线程同时读共享变量,适用于读多写少的场景”。那在读多写少的场景中,还有没有更快的技术方案呢?还真有,Java在1.8这个版本里,提供了一种叫StampedLock的锁,它的性能就比读写锁还要好。
|
||||||
|
|
||||||
|
下面我们就来介绍一下StampedLock的使用方法、内部工作原理以及在使用过程中需要注意的事项。
|
||||||
|
|
||||||
|
StampedLock支持的三种锁模式
|
||||||
|
|
||||||
|
我们先来看看在使用上StampedLock和上一篇文章讲的ReadWriteLock有哪些区别。
|
||||||
|
|
||||||
|
ReadWriteLock支持两种模式:一种是读锁,一种是写锁。而StampedLock支持三种模式,分别是:写锁、悲观读锁和乐观读。其中,写锁、悲观读锁的语义和ReadWriteLock的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。不同的是:StampedLock里的写锁和悲观读锁加锁成功之后,都会返回一个stamp;然后解锁的时候,需要传入这个stamp。相关的示例代码如下。
|
||||||
|
|
||||||
|
final StampedLock sl =
|
||||||
|
new StampedLock();
|
||||||
|
|
||||||
|
// 获取/释放悲观读锁示意代码
|
||||||
|
long stamp = sl.readLock();
|
||||||
|
try {
|
||||||
|
//省略业务相关代码
|
||||||
|
} finally {
|
||||||
|
sl.unlockRead(stamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取/释放写锁示意代码
|
||||||
|
long stamp = sl.writeLock();
|
||||||
|
try {
|
||||||
|
//省略业务相关代码
|
||||||
|
} finally {
|
||||||
|
sl.unlockWrite(stamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
StampedLock的性能之所以比ReadWriteLock还要好,其关键是StampedLock支持乐观读的方式。ReadWriteLock支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;而StampedLock提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞。
|
||||||
|
|
||||||
|
注意这里,我们用的是“乐观读”这个词,而不是“乐观读锁”,是要提醒你,乐观读这个操作是无锁的,所以相比较ReadWriteLock的读锁,乐观读的性能更好一些。
|
||||||
|
|
||||||
|
文中下面这段代码是出自Java SDK官方示例,并略做了修改。在distanceFromOrigin()这个方法中,首先通过调用tryOptimisticRead()获取了一个stamp,这里的tryOptimisticRead()就是我们前面提到的乐观读。之后将共享变量x和y读入方法的局部变量中,不过需要注意的是,由于tryOptimisticRead()是无锁的,所以共享变量x和y读入方法局部变量时,x和y有可能被其他线程修改了。因此最后读完之后,还需要再次验证一下是否存在写操作,这个验证操作是通过调用validate(stamp)来实现的。
|
||||||
|
|
||||||
|
class Point {
|
||||||
|
private int x, y;
|
||||||
|
final StampedLock sl =
|
||||||
|
new StampedLock();
|
||||||
|
//计算到原点的距离
|
||||||
|
int distanceFromOrigin() {
|
||||||
|
// 乐观读
|
||||||
|
long stamp =
|
||||||
|
sl.tryOptimisticRead();
|
||||||
|
// 读入局部变量,
|
||||||
|
// 读的过程数据可能被修改
|
||||||
|
int curX = x, curY = y;
|
||||||
|
//判断执行读操作期间,
|
||||||
|
//是否存在写操作,如果存在,
|
||||||
|
//则sl.validate返回false
|
||||||
|
if (!sl.validate(stamp)){
|
||||||
|
// 升级为悲观读锁
|
||||||
|
stamp = sl.readLock();
|
||||||
|
try {
|
||||||
|
curX = x;
|
||||||
|
curY = y;
|
||||||
|
} finally {
|
||||||
|
//释放悲观读锁
|
||||||
|
sl.unlockRead(stamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.sqrt(
|
||||||
|
curX * curX + curY * curY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
在上面这个代码示例中,如果执行乐观读操作的期间,存在写操作,会把乐观读升级为悲观读锁。这个做法挺合理的,否则你就需要在一个循环里反复执行乐观读,直到执行乐观读操作的期间没有写操作(只有这样才能保证x和y的正确性和一致性),而循环读会浪费大量的CPU。升级为悲观读锁,代码简练且不易出错,建议你在具体实践时也采用这样的方法。
|
||||||
|
|
||||||
|
进一步理解乐观读
|
||||||
|
|
||||||
|
如果你曾经用过数据库的乐观锁,可能会发现StampedLock的乐观读和数据库的乐观锁有异曲同工之妙。的确是这样的,就拿我个人来说,我是先接触的数据库里的乐观锁,然后才接触的StampedLock,我就觉得我前期数据库里乐观锁的学习对于后面理解StampedLock的乐观读有很大帮助,所以这里有必要再介绍一下数据库里的乐观锁。
|
||||||
|
|
||||||
|
还记得我第一次使用数据库乐观锁的场景是这样的:在ERP的生产模块里,会有多个人通过ERP系统提供的UI同时修改同一条生产订单,那如何保证生产订单数据是并发安全的呢?我采用的方案就是乐观锁。
|
||||||
|
|
||||||
|
乐观锁的实现很简单,在生产订单的表 product_doc 里增加了一个数值型版本号字段 version,每次更新product_doc这个表的时候,都将 version 字段加1。生产订单的UI在展示的时候,需要查询数据库,此时将这个 version 字段和其他业务字段一起返回给生产订单UI。假设用户查询的生产订单的id=777,那么SQL语句类似下面这样:
|
||||||
|
|
||||||
|
select id,... ,version
|
||||||
|
from product_doc
|
||||||
|
where id=777
|
||||||
|
|
||||||
|
|
||||||
|
用户在生产订单UI执行保存操作的时候,后台利用下面的SQL语句更新生产订单,此处我们假设该条生产订单的 version=9。
|
||||||
|
|
||||||
|
update product_doc
|
||||||
|
set version=version+1,...
|
||||||
|
where id=777 and version=9
|
||||||
|
|
||||||
|
|
||||||
|
如果这条SQL语句执行成功并且返回的条数等于1,那么说明从生产订单UI执行查询操作到执行保存操作期间,没有其他人修改过这条数据。因为如果这期间其他人修改过这条数据,那么版本号字段一定会大于9。
|
||||||
|
|
||||||
|
你会发现数据库里的乐观锁,查询的时候需要把 version 字段查出来,更新的时候要利用 version 字段做验证。这个 version 字段就类似于StampedLock里面的stamp。这样对比着看,相信你会更容易理解StampedLock里乐观读的用法。
|
||||||
|
|
||||||
|
StampedLock使用注意事项
|
||||||
|
|
||||||
|
对于读多写少的场景StampedLock性能很好,简单的应用场景基本上可以替代ReadWriteLock,但是StampedLock的功能仅仅是ReadWriteLock的子集,在使用的时候,还是有几个地方需要注意一下。
|
||||||
|
|
||||||
|
StampedLock在命名上并没有增加Reentrant,想必你已经猜测到StampedLock应该是不可重入的。事实上,的确是这样的,StampedLock不支持重入。这个是在使用中必须要特别注意的。
|
||||||
|
|
||||||
|
另外,StampedLock的悲观读锁、写锁都不支持条件变量,这个也需要你注意。
|
||||||
|
|
||||||
|
还有一点需要特别注意,那就是:如果线程阻塞在StampedLock的readLock()或者writeLock()上时,此时调用该阻塞线程的interrupt()方法,会导致CPU飙升。例如下面的代码中,线程T1获取写锁之后将自己阻塞,线程T2尝试获取悲观读锁,也会阻塞;如果此时调用线程T2的interrupt()方法来中断线程T2的话,你会发现线程T2所在CPU会飙升到100%。
|
||||||
|
|
||||||
|
final StampedLock lock
|
||||||
|
= new StampedLock();
|
||||||
|
Thread T1 = new Thread(()->{
|
||||||
|
// 获取写锁
|
||||||
|
lock.writeLock();
|
||||||
|
// 永远阻塞在此处,不释放写锁
|
||||||
|
LockSupport.park();
|
||||||
|
});
|
||||||
|
T1.start();
|
||||||
|
// 保证T1获取写锁
|
||||||
|
Thread.sleep(100);
|
||||||
|
Thread T2 = new Thread(()->
|
||||||
|
//阻塞在悲观读锁
|
||||||
|
lock.readLock()
|
||||||
|
);
|
||||||
|
T2.start();
|
||||||
|
// 保证T2阻塞在读锁
|
||||||
|
Thread.sleep(100);
|
||||||
|
//中断线程T2
|
||||||
|
//会导致线程T2所在CPU飙升
|
||||||
|
T2.interrupt();
|
||||||
|
T2.join();
|
||||||
|
|
||||||
|
|
||||||
|
所以,使用StampedLock一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁readLockInterruptibly()和写锁writeLockInterruptibly()。这个规则一定要记清楚。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
StampedLock的使用看上去有点复杂,但是如果你能理解乐观锁背后的原理,使用起来还是比较流畅的。建议你认真揣摩Java的官方示例,这个示例基本上就是一个最佳实践。我们把Java官方示例精简后,形成下面的代码模板,建议你在实际工作中尽量按照这个模板来使用StampedLock。
|
||||||
|
|
||||||
|
StampedLock读模板:
|
||||||
|
|
||||||
|
final StampedLock sl =
|
||||||
|
new StampedLock();
|
||||||
|
|
||||||
|
// 乐观读
|
||||||
|
long stamp =
|
||||||
|
sl.tryOptimisticRead();
|
||||||
|
// 读入方法局部变量
|
||||||
|
......
|
||||||
|
// 校验stamp
|
||||||
|
if (!sl.validate(stamp)){
|
||||||
|
// 升级为悲观读锁
|
||||||
|
stamp = sl.readLock();
|
||||||
|
try {
|
||||||
|
// 读入方法局部变量
|
||||||
|
.....
|
||||||
|
} finally {
|
||||||
|
//释放悲观读锁
|
||||||
|
sl.unlockRead(stamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//使用方法局部变量执行业务操作
|
||||||
|
......
|
||||||
|
|
||||||
|
|
||||||
|
StampedLock写模板:
|
||||||
|
|
||||||
|
long stamp = sl.writeLock();
|
||||||
|
try {
|
||||||
|
// 写共享变量
|
||||||
|
......
|
||||||
|
} finally {
|
||||||
|
sl.unlockWrite(stamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
StampedLock支持锁的降级(通过tryConvertToReadLock()方法实现)和升级(通过tryConvertToWriteLock()方法实现),但是建议你要慎重使用。下面的代码也源自Java的官方示例,我仅仅做了一点修改,隐藏了一个Bug,你来看看Bug出在哪里吧。
|
||||||
|
|
||||||
|
private double x, y;
|
||||||
|
final StampedLock sl = new StampedLock();
|
||||||
|
// 存在问题的方法
|
||||||
|
void moveIfAtOrigin(double newX, double newY){
|
||||||
|
long stamp = sl.readLock();
|
||||||
|
try {
|
||||||
|
while(x == 0.0 && y == 0.0){
|
||||||
|
long ws = sl.tryConvertToWriteLock(stamp);
|
||||||
|
if (ws != 0L) {
|
||||||
|
x = newX;
|
||||||
|
y = newY;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
sl.unlockRead(stamp);
|
||||||
|
stamp = sl.writeLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sl.unlock(stamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
225
专栏/Java并发编程实战/19CountDownLatch和CyclicBarrier:如何让多线程步调一致?.md
Normal file
225
专栏/Java并发编程实战/19CountDownLatch和CyclicBarrier:如何让多线程步调一致?.md
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
19 CountDownLatch和CyclicBarrier:如何让多线程步调一致?
|
||||||
|
前几天老板突然匆匆忙忙过来,说对账系统最近越来越慢了,能不能快速优化一下。我了解了对账系统的业务后,发现还是挺简单的,用户通过在线商城下单,会生成电子订单,保存在订单库;之后物流会生成派送单给用户发货,派送单保存在派送单库。为了防止漏派送或者重复派送,对账系统每天还会校验是否存在异常订单。
|
||||||
|
|
||||||
|
对账系统的处理逻辑很简单,你可以参考下面的对账系统流程图。目前对账系统的处理逻辑是首先查询订单,然后查询派送单,之后对比订单和派送单,将差异写入差异库。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
对账系统流程图
|
||||||
|
|
||||||
|
对账系统的代码抽象之后,也很简单,核心代码如下,就是在一个单线程里面循环查询订单、派送单,然后执行对账,最后将写入差异库。
|
||||||
|
|
||||||
|
while(存在未对账订单){
|
||||||
|
// 查询未对账订单
|
||||||
|
pos = getPOrders();
|
||||||
|
// 查询派送单
|
||||||
|
dos = getDOrders();
|
||||||
|
// 执行对账操作
|
||||||
|
diff = check(pos, dos);
|
||||||
|
// 差异写入差异库
|
||||||
|
save(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
利用并行优化对账系统
|
||||||
|
|
||||||
|
老板要我优化性能,那我就首先要找到这个对账系统的瓶颈所在。
|
||||||
|
|
||||||
|
目前的对账系统,由于订单量和派送单量巨大,所以查询未对账订单getPOrders()和查询派送单getDOrders()相对较慢,那有没有办法快速优化一下呢?目前对账系统是单线程执行的,图形化后是下图这个样子。对于串行化的系统,优化性能首先想到的是能否利用多线程并行处理。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
对账系统单线程执行示意图
|
||||||
|
|
||||||
|
所以,这里你应该能够看出来这个对账系统里的瓶颈:查询未对账订单getPOrders()和查询派送单getDOrders()是否可以并行处理呢?显然是可以的,因为这两个操作并没有先后顺序的依赖。这两个最耗时的操作并行之后,执行过程如下图所示。对比一下单线程的执行示意图,你会发现同等时间里,并行执行的吞吐量近乎单线程的2倍,优化效果还是相对明显的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
对账系统并行执行示意图
|
||||||
|
|
||||||
|
思路有了,下面我们再来看看如何用代码实现。在下面的代码中,我们创建了两个线程T1和T2,并行执行查询未对账订单getPOrders()和查询派送单getDOrders()这两个操作。在主线程中执行对账操作check()和差异写入save()两个操作。不过需要注意的是:主线程需要等待线程T1和T2执行完才能执行check()和save()这两个操作,为此我们通过调用T1.join()和T2.join()来实现等待,当T1和T2线程退出时,调用T1.join()和T2.join()的主线程就会从阻塞态被唤醒,从而执行之后的check()和save()。
|
||||||
|
|
||||||
|
while(存在未对账订单){
|
||||||
|
// 查询未对账订单
|
||||||
|
Thread T1 = new Thread(()->{
|
||||||
|
pos = getPOrders();
|
||||||
|
});
|
||||||
|
T1.start();
|
||||||
|
// 查询派送单
|
||||||
|
Thread T2 = new Thread(()->{
|
||||||
|
dos = getDOrders();
|
||||||
|
});
|
||||||
|
T2.start();
|
||||||
|
// 等待T1、T2结束
|
||||||
|
T1.join();
|
||||||
|
T2.join();
|
||||||
|
// 执行对账操作
|
||||||
|
diff = check(pos, dos);
|
||||||
|
// 差异写入差异库
|
||||||
|
save(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
用CountDownLatch实现线程等待
|
||||||
|
|
||||||
|
经过上面的优化之后,基本上可以跟老板汇报收工了,但还是有点美中不足,相信你也发现了,while循环里面每次都会创建新的线程,而创建线程可是个耗时的操作。所以最好是创建出来的线程能够循环利用,估计这时你已经想到线程池了,是的,线程池就能解决这个问题。
|
||||||
|
|
||||||
|
而下面的代码就是用线程池优化后的:我们首先创建了一个固定大小为2的线程池,之后在while循环里重复利用。一切看上去都很顺利,但是有个问题好像无解了,那就是主线程如何知道getPOrders()和getDOrders()这两个操作什么时候执行完。前面主线程通过调用线程T1和T2的join()方法来等待线程T1和T2退出,但是在线程池的方案里,线程根本就不会退出,所以join()方法已经失效了。
|
||||||
|
|
||||||
|
// 创建2个线程的线程池
|
||||||
|
Executor executor =
|
||||||
|
Executors.newFixedThreadPool(2);
|
||||||
|
while(存在未对账订单){
|
||||||
|
// 查询未对账订单
|
||||||
|
executor.execute(()-> {
|
||||||
|
pos = getPOrders();
|
||||||
|
});
|
||||||
|
// 查询派送单
|
||||||
|
executor.execute(()-> {
|
||||||
|
dos = getDOrders();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ??如何实现等待??*/
|
||||||
|
|
||||||
|
// 执行对账操作
|
||||||
|
diff = check(pos, dos);
|
||||||
|
// 差异写入差异库
|
||||||
|
save(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
那如何解决这个问题呢?你可以开动脑筋想出很多办法,最直接的办法是弄一个计数器,初始值设置成2,当执行完pos = getPOrders();这个操作之后将计数器减1,执行完dos = getDOrders();之后也将计数器减1,在主线程里,等待计数器等于0;当计数器等于0时,说明这两个查询操作执行完了。等待计数器等于0其实就是一个条件变量,用管程实现起来也很简单。
|
||||||
|
|
||||||
|
不过我并不建议你在实际项目中去实现上面的方案,因为Java并发包里已经提供了实现类似功能的工具类:CountDownLatch,我们直接使用就可以了。下面的代码示例中,在while循环里面,我们首先创建了一个CountDownLatch,计数器的初始值等于2,之后在pos = getPOrders();和dos = getDOrders();两条语句的后面对计数器执行减1操作,这个对计数器减1的操作是通过调用 latch.countDown(); 来实现的。在主线程中,我们通过调用 latch.await() 来实现对计数器等于0的等待。
|
||||||
|
|
||||||
|
// 创建2个线程的线程池
|
||||||
|
Executor executor =
|
||||||
|
Executors.newFixedThreadPool(2);
|
||||||
|
while(存在未对账订单){
|
||||||
|
// 计数器初始化为2
|
||||||
|
CountDownLatch latch =
|
||||||
|
new CountDownLatch(2);
|
||||||
|
// 查询未对账订单
|
||||||
|
executor.execute(()-> {
|
||||||
|
pos = getPOrders();
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
// 查询派送单
|
||||||
|
executor.execute(()-> {
|
||||||
|
dos = getDOrders();
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待两个查询操作结束
|
||||||
|
latch.await();
|
||||||
|
|
||||||
|
// 执行对账操作
|
||||||
|
diff = check(pos, dos);
|
||||||
|
// 差异写入差异库
|
||||||
|
save(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
进一步优化性能
|
||||||
|
|
||||||
|
经过上面的重重优化之后,长出一口气,终于可以交付了。不过在交付之前还需要再次审视一番,看看还有没有优化的余地,仔细看还是有的。
|
||||||
|
|
||||||
|
前面我们将getPOrders()和getDOrders()这两个查询操作并行了,但这两个查询操作和对账操作check()、save()之间还是串行的。很显然,这两个查询操作和对账操作也是可以并行的,也就是说,在执行对账操作的时候,可以同时去执行下一轮的查询操作,这个过程可以形象化地表述为下面这幅示意图。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
完全并行执行示意图
|
||||||
|
|
||||||
|
那接下来我们再来思考一下如何实现这步优化,两次查询操作能够和对账操作并行,对账操作还依赖查询操作的结果,这明显有点生产者-消费者的意思,两次查询操作是生产者,对账操作是消费者。既然是生产者-消费者模型,那就需要有个队列,来保存生产者生产的数据,而消费者则从这个队列消费数据。
|
||||||
|
|
||||||
|
不过针对对账这个项目,我设计了两个队列,并且两个队列的元素之间还有对应关系。具体如下图所示,订单查询操作将订单查询结果插入订单队列,派送单查询操作将派送单插入派送单队列,这两个队列的元素之间是有一一对应的关系的。两个队列的好处是,对账操作可以每次从订单队列出一个元素,从派送单队列出一个元素,然后对这两个元素执行对账操作,这样数据一定不会乱掉。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
双队列示意图
|
||||||
|
|
||||||
|
下面再来看如何用双队列来实现完全的并行。一个最直接的想法是:一个线程T1执行订单的查询工作,一个线程T2执行派送单的查询工作,当线程T1和T2都各自生产完1条数据的时候,通知线程T3执行对账操作。这个想法虽看上去简单,但其实还隐藏着一个条件,那就是线程T1和线程T2的工作要步调一致,不能一个跑得太快,一个跑得太慢,只有这样才能做到各自生产完1条数据的时候,通知线程T3。
|
||||||
|
|
||||||
|
下面这幅图形象地描述了上面的意图:线程T1和线程T2只有都生产完1条数据的时候,才能一起向下执行,也就是说,线程T1和线程T2要互相等待,步调要一致;同时当线程T1和T2都生产完一条数据的时候,还要能够通知线程T3执行对账操作。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
同步执行示意图
|
||||||
|
|
||||||
|
用CyclicBarrier实现线程同步
|
||||||
|
|
||||||
|
下面我们就来实现上面提到的方案。这个方案的难点有两个:一个是线程T1和T2要做到步调一致,另一个是要能够通知到线程T3。
|
||||||
|
|
||||||
|
你依然可以利用一个计数器来解决这两个难点,计数器初始化为2,线程T1和T2生产完一条数据都将计数器减1,如果计数器大于0则线程T1或者T2等待。如果计数器等于0,则通知线程T3,并唤醒等待的线程T1或者T2,与此同时,将计数器重置为2,这样线程T1和线程T2生产下一条数据的时候就可以继续使用这个计数器了。
|
||||||
|
|
||||||
|
同样,还是建议你不要在实际项目中这么做,因为Java并发包里也已经提供了相关的工具类:CyclicBarrier。在下面的代码中,我们首先创建了一个计数器初始值为2的CyclicBarrier,你需要注意的是创建CyclicBarrier的时候,我们还传入了一个回调函数,当计数器减到0的时候,会调用这个回调函数。
|
||||||
|
|
||||||
|
线程T1负责查询订单,当查出一条时,调用 barrier.await() 来将计数器减1,同时等待计数器变成0;线程T2负责查询派送单,当查出一条时,也调用 barrier.await() 来将计数器减1,同时等待计数器变成0;当T1和T2都调用 barrier.await() 的时候,计数器会减到0,此时T1和T2就可以执行下一条语句了,同时会调用barrier的回调函数来执行对账操作。
|
||||||
|
|
||||||
|
非常值得一提的是,CyclicBarrier的计数器有自动重置的功能,当减到0的时候,会自动重置你设置的初始值。这个功能用起来实在是太方便了。
|
||||||
|
|
||||||
|
// 订单队列
|
||||||
|
Vector<P> pos;
|
||||||
|
// 派送单队列
|
||||||
|
Vector<D> dos;
|
||||||
|
// 执行回调的线程池
|
||||||
|
Executor executor =
|
||||||
|
Executors.newFixedThreadPool(1);
|
||||||
|
final CyclicBarrier barrier =
|
||||||
|
new CyclicBarrier(2, ()->{
|
||||||
|
executor.execute(()->check());
|
||||||
|
});
|
||||||
|
|
||||||
|
void check(){
|
||||||
|
P p = pos.remove(0);
|
||||||
|
D d = dos.remove(0);
|
||||||
|
// 执行对账操作
|
||||||
|
diff = check(p, d);
|
||||||
|
// 差异写入差异库
|
||||||
|
save(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkAll(){
|
||||||
|
// 循环查询订单库
|
||||||
|
Thread T1 = new Thread(()->{
|
||||||
|
while(存在未对账订单){
|
||||||
|
// 查询订单库
|
||||||
|
pos.add(getPOrders());
|
||||||
|
// 等待
|
||||||
|
barrier.await();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
T1.start();
|
||||||
|
// 循环查询运单库
|
||||||
|
Thread T2 = new Thread(()->{
|
||||||
|
while(存在未对账订单){
|
||||||
|
// 查询运单库
|
||||||
|
dos.add(getDOrders());
|
||||||
|
// 等待
|
||||||
|
barrier.await();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
T2.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
CountDownLatch和CyclicBarrier是Java并发包提供的两个非常易用的线程同步工具类,这两个工具类用法的区别在这里还是有必要再强调一下:CountDownLatch主要用来解决一个线程等待多个线程的场景,可以类比旅游团团长要等待所有的游客到齐才能去下一个景点;而CyclicBarrier是一组线程之间互相等待,更像是几个驴友之间不离不弃。除此之外CountDownLatch的计数器是不能循环利用的,也就是说一旦计数器减到0,再有线程调用await(),该线程会直接通过。但CyclicBarrier的计数器是可以循环利用的,而且具备自动重置的功能,一旦计数器减到0会自动重置到你设置的初始值。除此之外,CyclicBarrier还可以设置回调函数,可以说是功能丰富。
|
||||||
|
|
||||||
|
本章的示例代码中有两处用到了线程池,你现在只需要大概了解即可,因为线程池相关的知识咱们专栏后面还会有详细介绍。另外,线程池提供了Future特性,我们也可以利用Future特性来实现线程之间的等待,这个后面我们也会详细介绍。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
本章最后的示例代码中,CyclicBarrier的回调函数我们使用了一个固定大小的线程池,你觉得是否有必要呢?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
161
专栏/Java并发编程实战/20并发容器:都有哪些“坑”需要我们填?.md
Normal file
161
专栏/Java并发编程实战/20并发容器:都有哪些“坑”需要我们填?.md
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
20 并发容器:都有哪些“坑”需要我们填?
|
||||||
|
Java并发包有很大一部分内容都是关于并发容器的,因此学习和搞懂这部分的内容很有必要。
|
||||||
|
|
||||||
|
Java 1.5之前提供的同步容器虽然也能保证线程安全,但是性能很差,而Java 1.5版本之后提供的并发容器在性能方面则做了很多优化,并且容器的类型也更加丰富了。下面我们就对比二者来学习这部分的内容。
|
||||||
|
|
||||||
|
同步容器及其注意事项
|
||||||
|
|
||||||
|
Java中的容器主要可以分为四个大类,分别是List、Map、Set和Queue,但并不是所有的Java容器都是线程安全的。例如,我们常用的ArrayList、HashMap就不是线程安全的。在介绍线程安全的容器之前,我们先思考这样一个问题:如何将非线程安全的容器变成线程安全的容器?
|
||||||
|
|
||||||
|
在前面《12 | 如何用面向对象思想写好并发程序?》我们讲过实现思路其实很简单,只要把非线程安全的容器封装在对象内部,然后控制好访问路径就可以了。
|
||||||
|
|
||||||
|
下面我们就以ArrayList为例,看看如何将它变成线程安全的。在下面的代码中,SafeArrayList内部持有一个ArrayList的实例c,所有访问c的方法我们都增加了synchronized关键字,需要注意的是我们还增加了一个addIfNotExist()方法,这个方法也是用synchronized来保证原子性的。
|
||||||
|
|
||||||
|
SafeArrayList<T>{
|
||||||
|
//封装ArrayList
|
||||||
|
List<T> c = new ArrayList<>();
|
||||||
|
//控制访问路径
|
||||||
|
synchronized
|
||||||
|
T get(int idx){
|
||||||
|
return c.get(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized
|
||||||
|
void add(int idx, T t) {
|
||||||
|
c.add(idx, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized
|
||||||
|
boolean addIfNotExist(T t){
|
||||||
|
if(!c.contains(t)) {
|
||||||
|
c.add(t);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
看到这里,你可能会举一反三,然后想到:所有非线程安全的类是不是都可以用这种包装的方式来实现线程安全呢?其实这一点不止你想到了,Java SDK的开发人员也想到了,所以他们在Collections这个类中还提供了一套完备的包装类,比如下面的示例代码中,分别把ArrayList、HashSet和HashMap包装成了线程安全的List、Set和Map。
|
||||||
|
|
||||||
|
List list = Collections.
|
||||||
|
synchronizedList(new ArrayList());
|
||||||
|
Set set = Collections.
|
||||||
|
synchronizedSet(new HashSet());
|
||||||
|
Map map = Collections.
|
||||||
|
synchronizedMap(new HashMap());
|
||||||
|
|
||||||
|
|
||||||
|
我们曾经多次强调,组合操作需要注意竞态条件问题,例如上面提到的addIfNotExist()方法就包含组合操作。组合操作往往隐藏着竞态条件问题,即便每个操作都能保证原子性,也并不能保证组合操作的原子性,这个一定要注意。
|
||||||
|
|
||||||
|
在容器领域一个容易被忽视的“坑”是用迭代器遍历容器,例如在下面的代码中,通过迭代器遍历容器list,对每个元素调用foo()方法,这就存在并发问题,这些组合的操作不具备原子性。
|
||||||
|
|
||||||
|
List list = Collections.
|
||||||
|
synchronizedList(new ArrayList());
|
||||||
|
Iterator i = list.iterator();
|
||||||
|
while (i.hasNext())
|
||||||
|
foo(i.next());
|
||||||
|
|
||||||
|
|
||||||
|
而正确做法是下面这样,锁住list之后再执行遍历操作。如果你查看Collections内部的包装类源码,你会发现包装类的公共方法锁的是对象的this,其实就是我们这里的list,所以锁住list绝对是线程安全的。
|
||||||
|
|
||||||
|
List list = Collections.
|
||||||
|
synchronizedList(new ArrayList());
|
||||||
|
synchronized (list) {
|
||||||
|
Iterator i = list.iterator();
|
||||||
|
while (i.hasNext())
|
||||||
|
foo(i.next());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
上面我们提到的这些经过包装后线程安全容器,都是基于synchronized这个同步关键字实现的,所以也被称为同步容器。Java提供的同步容器还有Vector、Stack和Hashtable,这三个容器不是基于包装类实现的,但同样是基于synchronized实现的,对这三个容器的遍历,同样要加锁保证互斥。
|
||||||
|
|
||||||
|
并发容器及其注意事项
|
||||||
|
|
||||||
|
Java在1.5版本之前所谓的线程安全的容器,主要指的就是同步容器。不过同步容器有个最大的问题,那就是性能差,所有方法都用synchronized来保证互斥,串行度太高了。因此Java在1.5及之后版本提供了性能更高的容器,我们一般称为并发容器。
|
||||||
|
|
||||||
|
并发容器虽然数量非常多,但依然是前面我们提到的四大类:List、Map、Set和Queue,下面的并发容器关系图,基本上把我们经常用的容器都覆盖到了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
并发容器关系图
|
||||||
|
|
||||||
|
鉴于并发容器的数量太多,再加上篇幅限制,所以我并不会一一详细介绍它们的用法,只是把关键点介绍一下。
|
||||||
|
|
||||||
|
(一)List
|
||||||
|
|
||||||
|
List里面只有一个实现类就是CopyOnWriteArrayList。CopyOnWrite,顾名思义就是写的时候会将共享变量新复制一份出来,这样做的好处是读操作完全无锁。
|
||||||
|
|
||||||
|
那CopyOnWriteArrayList的实现原理是怎样的呢?下面我们就来简单介绍一下
|
||||||
|
|
||||||
|
CopyOnWriteArrayList内部维护了一个数组,成员变量array就指向这个内部数组,所有的读操作都是基于array进行的,如下图所示,迭代器Iterator遍历的就是array数组。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
执行迭代的内部结构图
|
||||||
|
|
||||||
|
如果在遍历array的同时,还有一个写操作,例如增加元素,CopyOnWriteArrayList是如何处理的呢?CopyOnWriteArrayList会将array复制一份,然后在新复制处理的数组上执行增加元素的操作,执行完之后再将array指向这个新的数组。通过下图你可以看到,读写是可以并行的,遍历操作一直都是基于原array执行,而写操作则是基于新array进行。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
执行增加元素的内部结构图
|
||||||
|
|
||||||
|
使用CopyOnWriteArrayList需要注意的“坑”主要有两个方面。一个是应用场景,CopyOnWriteArrayList仅适用于写操作非常少的场景,而且能够容忍读写的短暂不一致。例如上面的例子中,写入的新元素并不能立刻被遍历到。另一个需要注意的是,CopyOnWriteArrayList迭代器是只读的,不支持增删改。因为迭代器遍历的仅仅是一个快照,而对快照进行增删改是没有意义的。
|
||||||
|
|
||||||
|
(二)Map
|
||||||
|
|
||||||
|
Map接口的两个实现是ConcurrentHashMap和ConcurrentSkipListMap,它们从应用的角度来看,主要区别在于ConcurrentHashMap的key是无序的,而ConcurrentSkipListMap的key是有序的。所以如果你需要保证key的顺序,就只能使用ConcurrentSkipListMap。
|
||||||
|
|
||||||
|
使用ConcurrentHashMap和ConcurrentSkipListMap需要注意的地方是,它们的key和value都不能为空,否则会抛出NullPointerException这个运行时异常。下面这个表格总结了Map相关的实现类对于key和value的要求,你可以对比学习。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ConcurrentSkipListMap里面的SkipList本身就是一种数据结构,中文一般都翻译为“跳表”。跳表插入、删除、查询操作平均的时间复杂度是 O(log n),理论上和并发线程数没有关系,所以在并发度非常高的情况下,若你对ConcurrentHashMap的性能还不满意,可以尝试一下ConcurrentSkipListMap。
|
||||||
|
|
||||||
|
(三)Set
|
||||||
|
|
||||||
|
Set接口的两个实现是CopyOnWriteArraySet和ConcurrentSkipListSet,使用场景可以参考前面讲述的CopyOnWriteArrayList和ConcurrentSkipListMap,它们的原理都是一样的,这里就不再赘述了。
|
||||||
|
|
||||||
|
(四)Queue
|
||||||
|
|
||||||
|
Java并发包里面Queue这类并发容器是最复杂的,你可以从以下两个维度来分类。一个维度是阻塞与非阻塞,所谓阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞。另一个维度是单端与双端,单端指的是只能队尾入队,队首出队;而双端指的是队首队尾皆可入队出队。Java并发包里阻塞队列都用Blocking关键字标识,单端队列使用Queue标识,双端队列使用Deque标识。
|
||||||
|
|
||||||
|
这两个维度组合后,可以将Queue细分为四大类,分别是:
|
||||||
|
|
||||||
|
1.单端阻塞队列:其实现有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、LinkedTransferQueue、PriorityBlockingQueue和DelayQueue。内部一般会持有一个队列,这个队列可以是数组(其实现是ArrayBlockingQueue)也可以是链表(其实现是LinkedBlockingQueue);甚至还可以不持有队列(其实现是SynchronousQueue),此时生产者线程的入队操作必须等待消费者线程的出队操作。而LinkedTransferQueue融合LinkedBlockingQueue和SynchronousQueue的功能,性能比LinkedBlockingQueue更好;PriorityBlockingQueue支持按照优先级出队;DelayQueue支持延时出队。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
单端阻塞队列示意图
|
||||||
|
|
||||||
|
2.双端阻塞队列:其实现是LinkedBlockingDeque。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
双端阻塞队列示意图
|
||||||
|
|
||||||
|
3.单端非阻塞队列:其实现是ConcurrentLinkedQueue。-
|
||||||
|
4.双端非阻塞队列:其实现是ConcurrentLinkedDeque。
|
||||||
|
|
||||||
|
另外,使用队列时,需要格外注意队列是否支持有界(所谓有界指的是内部的队列是否有容量限制)。实际工作中,一般都不建议使用无界的队列,因为数据量大了之后很容易导致OOM。上面我们提到的这些Queue中,只有ArrayBlockingQueue和LinkedBlockingQueue是支持有界的,所以在使用其他无界队列时,一定要充分考虑是否存在导致OOM的隐患。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Java并发容器的内容很多,但鉴于篇幅有限,我们只是对一些关键点进行了梳理和介绍。
|
||||||
|
|
||||||
|
而在实际工作中,你不单要清楚每种容器的特性,还要能选对容器,这才是关键,至于每种容器的用法,用的时候看一下API说明就可以了,这些容器的使用都不难。在文中,我们甚至都没有介绍Java容器的快速失败机制(Fail-Fast),原因就在于当你选对容器的时候,根本不会触发它。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
线上系统CPU突然飙升,你怀疑有同学在并发场景里使用了HashMap,因为在1.8之前的版本里并发执行HashMap.put()可能会导致CPU飙升到100%,你觉得该如何验证你的猜测呢?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
265
专栏/Java并发编程实战/21原子类:无锁工具类的典范.md
Normal file
265
专栏/Java并发编程实战/21原子类:无锁工具类的典范.md
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
21 原子类:无锁工具类的典范
|
||||||
|
前面我们多次提到一个累加器的例子,示例代码如下。在这个例子中,add10K()这个方法不是线程安全的,问题就出在变量count的可见性和count+=1的原子性上。可见性问题可以用volatile来解决,而原子性问题我们前面一直都是采用的互斥锁方案。
|
||||||
|
|
||||||
|
public class Test {
|
||||||
|
long count = 0;
|
||||||
|
void add10K() {
|
||||||
|
int idx = 0;
|
||||||
|
while(idx++ < 10000) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
其实对于简单的原子性问题,还有一种无锁方案。Java SDK并发包将这种无锁方案封装提炼之后,实现了一系列的原子类。不过,在深入介绍原子类的实现之前,我们先看看如何利用原子类解决累加器问题,这样你会对原子类有个初步的认识。
|
||||||
|
|
||||||
|
在下面的代码中,我们将原来的long型变量count替换为了原子类AtomicLong,原来的 count +=1 替换成了 count.getAndIncrement(),仅需要这两处简单的改动就能使add10K()方法变成线程安全的,原子类的使用还是挺简单的。
|
||||||
|
|
||||||
|
public class Test {
|
||||||
|
AtomicLong count =
|
||||||
|
new AtomicLong(0);
|
||||||
|
void add10K() {
|
||||||
|
int idx = 0;
|
||||||
|
while(idx++ < 10000) {
|
||||||
|
count.getAndIncrement();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
无锁方案相对互斥锁方案,最大的好处就是性能。互斥锁方案为了保证互斥性,需要执行加锁、解锁操作,而加锁、解锁操作本身就消耗性能;同时拿不到锁的线程还会进入阻塞状态,进而触发线程切换,线程切换对性能的消耗也很大。 相比之下,无锁方案则完全没有加锁、解锁的性能消耗,同时还能保证互斥性,既解决了问题,又没有带来新的问题,可谓绝佳方案。那它是如何做到的呢?
|
||||||
|
|
||||||
|
无锁方案的实现原理
|
||||||
|
|
||||||
|
其实原子类性能高的秘密很简单,硬件支持而已。CPU为了解决并发问题,提供了CAS指令(CAS,全称是Compare And Swap,即“比较并交换”)。CAS指令包含3个参数:共享变量的内存地址A、用于比较的值B和共享变量的新值C;并且只有当内存中地址A处的值等于B时,才能将内存中地址A处的值更新为新值C。作为一条CPU指令,CAS指令本身是能够保证原子性的。
|
||||||
|
|
||||||
|
你可以通过下面CAS指令的模拟代码来理解CAS的工作原理。在下面的模拟程序中有两个参数,一个是期望值expect,另一个是需要写入的新值newValue,只有当目前count的值和期望值expect相等时,才会将count更新为newValue。
|
||||||
|
|
||||||
|
class SimulatedCAS{
|
||||||
|
int count;
|
||||||
|
synchronized int cas(
|
||||||
|
int expect, int newValue){
|
||||||
|
// 读目前count的值
|
||||||
|
int curValue = count;
|
||||||
|
// 比较目前count值是否==期望值
|
||||||
|
if(curValue == expect){
|
||||||
|
// 如果是,则更新count的值
|
||||||
|
count = newValue;
|
||||||
|
}
|
||||||
|
// 返回写入前的值
|
||||||
|
return curValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
你仔细地再次思考一下这句话,“只有当目前count的值和期望值expect相等时,才会将count更新为newValue。”要怎么理解这句话呢?
|
||||||
|
|
||||||
|
对于前面提到的累加器的例子,count += 1 的一个核心问题是:基于内存中count的当前值A计算出来的count+=1为A+1,在将A+1写入内存的时候,很可能此时内存中count已经被其他线程更新过了,这样就会导致错误地覆盖其他线程写入的值(如果你觉得理解起来还有困难,建议你再重新看看《01 | 可见性、原子性和有序性问题:并发编程Bug的源头》)。也就是说,只有当内存中count的值等于期望值A时,才能将内存中count的值更新为计算结果A+1,这不就是CAS的语义吗!
|
||||||
|
|
||||||
|
使用CAS来解决并发问题,一般都会伴随着自旋,而所谓自旋,其实就是循环尝试。例如,实现一个线程安全的count += 1操作,“CAS+自旋”的实现方案如下所示,首先计算newValue = count+1,如果cas(count,newValue)返回的值不等于count,则意味着线程在执行完代码①处之后,执行代码②处之前,count的值被其他线程更新过。那此时该怎么处理呢?可以采用自旋方案,就像下面代码中展示的,可以重新读count最新的值来计算newValue并尝试再次更新,直到成功。
|
||||||
|
|
||||||
|
class SimulatedCAS{
|
||||||
|
volatile int count;
|
||||||
|
// 实现count+=1
|
||||||
|
addOne(){
|
||||||
|
do {
|
||||||
|
newValue = count+1; //①
|
||||||
|
}while(count !=
|
||||||
|
cas(count,newValue) //②
|
||||||
|
}
|
||||||
|
// 模拟实现CAS,仅用来帮助理解
|
||||||
|
synchronized int cas(
|
||||||
|
int expect, int newValue){
|
||||||
|
// 读目前count的值
|
||||||
|
int curValue = count;
|
||||||
|
// 比较目前count值是否==期望值
|
||||||
|
if(curValue == expect){
|
||||||
|
// 如果是,则更新count的值
|
||||||
|
count= newValue;
|
||||||
|
}
|
||||||
|
// 返回写入前的值
|
||||||
|
return curValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
通过上面的示例代码,想必你已经发现了,CAS这种无锁方案,完全没有加锁、解锁操作,即便两个线程完全同时执行addOne()方法,也不会有线程被阻塞,所以相对于互斥锁方案来说,性能好了很多。
|
||||||
|
|
||||||
|
但是在CAS方案中,有一个问题可能会常被你忽略,那就是ABA的问题。什么是ABA问题呢?
|
||||||
|
|
||||||
|
前面我们提到“如果cas(count,newValue)返回的值不等于count,意味着线程在执行完代码①处之后,执行代码②处之前,count的值被其他线程更新过”,那如果cas(count,newValue)返回的值等于count,是否就能够认为count的值没有被其他线程更新过呢?显然不是的,假设count原本是A,线程T1在执行完代码①处之后,执行代码②处之前,有可能count被线程T2更新成了B,之后又被T3更新回了A,这样线程T1虽然看到的一直是A,但是其实已经被其他线程更新过了,这就是ABA问题。
|
||||||
|
|
||||||
|
可能大多数情况下我们并不关心ABA问题,例如数值的原子递增,但也不能所有情况下都不关心,例如原子化的更新对象很可能就需要关心ABA问题,因为两个A虽然相等,但是第二个A的属性可能已经发生变化了。所以在使用CAS方案的时候,一定要先check一下。
|
||||||
|
|
||||||
|
看Java如何实现原子化的count += 1
|
||||||
|
|
||||||
|
在本文开始部分,我们使用原子类AtomicLong的getAndIncrement()方法替代了count += 1,从而实现了线程安全。原子类AtomicLong的getAndIncrement()方法内部就是基于CAS实现的,下面我们来看看Java是如何使用CAS来实现原子化的count += 1的。
|
||||||
|
|
||||||
|
在Java 1.8版本中,getAndIncrement()方法会转调unsafe.getAndAddLong()方法。这里this和valueOffset两个参数可以唯一确定共享变量的内存地址。
|
||||||
|
|
||||||
|
final long getAndIncrement() {
|
||||||
|
return unsafe.getAndAddLong(
|
||||||
|
this, valueOffset, 1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
unsafe.getAndAddLong()方法的源码如下,该方法首先会在内存中读取共享变量的值,之后循环调用compareAndSwapLong()方法来尝试设置共享变量的值,直到成功为止。compareAndSwapLong()是一个native方法,只有当内存中共享变量的值等于expected时,才会将共享变量的值更新为x,并且返回true;否则返回fasle。compareAndSwapLong的语义和CAS指令的语义的差别仅仅是返回值不同而已。
|
||||||
|
|
||||||
|
public final long getAndAddLong(
|
||||||
|
Object o, long offset, long delta){
|
||||||
|
long v;
|
||||||
|
do {
|
||||||
|
// 读取内存中的值
|
||||||
|
v = getLongVolatile(o, offset);
|
||||||
|
} while (!compareAndSwapLong(
|
||||||
|
o, offset, v, v + delta));
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
//原子性地将变量更新为x
|
||||||
|
//条件是内存中的值等于expected
|
||||||
|
//更新成功则返回true
|
||||||
|
native boolean compareAndSwapLong(
|
||||||
|
Object o, long offset,
|
||||||
|
long expected,
|
||||||
|
long x);
|
||||||
|
|
||||||
|
|
||||||
|
另外,需要你注意的是,getAndAddLong()方法的实现,基本上就是CAS使用的经典范例。所以请你再次体会下面这段抽象后的代码片段,它在很多无锁程序中经常出现。Java提供的原子类里面CAS一般被实现为compareAndSet(),compareAndSet()的语义和CAS指令的语义的差别仅仅是返回值不同而已,compareAndSet()里面如果更新成功,则会返回true,否则返回false。
|
||||||
|
|
||||||
|
do {
|
||||||
|
// 获取当前值
|
||||||
|
oldV = xxxx;
|
||||||
|
// 根据当前值计算新值
|
||||||
|
newV = ...oldV...
|
||||||
|
}while(!compareAndSet(oldV,newV);
|
||||||
|
|
||||||
|
|
||||||
|
原子类概览
|
||||||
|
|
||||||
|
Java SDK并发包里提供的原子类内容很丰富,我们可以将它们分为五个类别:原子化的基本数据类型、原子化的对象引用类型、原子化数组、原子化对象属性更新器和原子化的累加器。这五个类别提供的方法基本上是相似的,并且每个类别都有若干原子类,你可以通过下面的原子类组成概览图来获得一个全局的印象。下面我们详细解读这五个类别。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
原子类组成概览图
|
||||||
|
|
||||||
|
1. 原子化的基本数据类型
|
||||||
|
|
||||||
|
相关实现有AtomicBoolean、AtomicInteger和AtomicLong,提供的方法主要有以下这些,详情你可以参考SDK的源代码,都很简单,这里就不详细介绍了。
|
||||||
|
|
||||||
|
getAndIncrement() //原子化i++
|
||||||
|
getAndDecrement() //原子化的i--
|
||||||
|
incrementAndGet() //原子化的++i
|
||||||
|
decrementAndGet() //原子化的--i
|
||||||
|
//当前值+=delta,返回+=前的值
|
||||||
|
getAndAdd(delta)
|
||||||
|
//当前值+=delta,返回+=后的值
|
||||||
|
addAndGet(delta)
|
||||||
|
//CAS操作,返回是否成功
|
||||||
|
compareAndSet(expect, update)
|
||||||
|
//以下四个方法
|
||||||
|
//新值可以通过传入func函数来计算
|
||||||
|
getAndUpdate(func)
|
||||||
|
updateAndGet(func)
|
||||||
|
getAndAccumulate(x,func)
|
||||||
|
accumulateAndGet(x,func)
|
||||||
|
|
||||||
|
|
||||||
|
2. 原子化的对象引用类型
|
||||||
|
|
||||||
|
相关实现有AtomicReference、AtomicStampedReference和AtomicMarkableReference,利用它们可以实现对象引用的原子化更新。AtomicReference提供的方法和原子化的基本数据类型差不多,这里不再赘述。不过需要注意的是,对象引用的更新需要重点关注ABA问题,AtomicStampedReference和AtomicMarkableReference这两个原子类可以解决ABA问题。
|
||||||
|
|
||||||
|
解决ABA问题的思路其实很简单,增加一个版本号维度就可以了,这个和我们在《18 | StampedLock:有没有比读写锁更快的锁?》介绍的乐观锁机制很类似,每次执行CAS操作,附加再更新一个版本号,只要保证版本号是递增的,那么即便A变成B之后再变回A,版本号也不会变回来(版本号递增的)。AtomicStampedReference实现的CAS方法就增加了版本号参数,方法签名如下:
|
||||||
|
|
||||||
|
boolean compareAndSet(
|
||||||
|
V expectedReference,
|
||||||
|
V newReference,
|
||||||
|
int expectedStamp,
|
||||||
|
int newStamp)
|
||||||
|
|
||||||
|
|
||||||
|
AtomicMarkableReference的实现机制则更简单,将版本号简化成了一个Boolean值,方法签名如下:
|
||||||
|
|
||||||
|
boolean compareAndSet(
|
||||||
|
V expectedReference,
|
||||||
|
V newReference,
|
||||||
|
boolean expectedMark,
|
||||||
|
boolean newMark)
|
||||||
|
|
||||||
|
|
||||||
|
3. 原子化数组
|
||||||
|
|
||||||
|
相关实现有AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray,利用这些原子类,我们可以原子化地更新数组里面的每一个元素。这些类提供的方法和原子化的基本数据类型的区别仅仅是:每个方法多了一个数组的索引参数,所以这里也不再赘述了。
|
||||||
|
|
||||||
|
4. 原子化对象属性更新器
|
||||||
|
|
||||||
|
相关实现有AtomicIntegerFieldUpdater、AtomicLongFieldUpdater和AtomicReferenceFieldUpdater,利用它们可以原子化地更新对象的属性,这三个方法都是利用反射机制实现的,创建更新器的方法如下:
|
||||||
|
|
||||||
|
public static <U>
|
||||||
|
AtomicXXXFieldUpdater<U>
|
||||||
|
newUpdater(Class<U> tclass,
|
||||||
|
String fieldName)
|
||||||
|
|
||||||
|
|
||||||
|
需要注意的是,对象属性必须是volatile类型的,只有这样才能保证可见性;如果对象属性不是volatile类型的,newUpdater()方法会抛出IllegalArgumentException这个运行时异常。
|
||||||
|
|
||||||
|
你会发现newUpdater()的方法参数只有类的信息,没有对象的引用,而更新对象的属性,一定需要对象的引用,那这个参数是在哪里传入的呢?是在原子操作的方法参数中传入的。例如compareAndSet()这个原子操作,相比原子化的基本数据类型多了一个对象引用obj。原子化对象属性更新器相关的方法,相比原子化的基本数据类型仅仅是多了对象引用参数,所以这里也不再赘述了。
|
||||||
|
|
||||||
|
boolean compareAndSet(
|
||||||
|
T obj,
|
||||||
|
int expect,
|
||||||
|
int update)
|
||||||
|
|
||||||
|
|
||||||
|
5. 原子化的累加器
|
||||||
|
|
||||||
|
DoubleAccumulator、DoubleAdder、LongAccumulator和LongAdder,这四个类仅仅用来执行累加操作,相比原子化的基本数据类型,速度更快,但是不支持compareAndSet()方法。如果你仅仅需要累加操作,使用原子化的累加器性能会更好。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
无锁方案相对于互斥锁方案,优点非常多,首先性能好,其次是基本不会出现死锁问题(但可能出现饥饿和活锁问题,因为自旋会反复重试)。Java提供的原子类大部分都实现了compareAndSet()方法,基于compareAndSet()方法,你可以构建自己的无锁数据结构,但是建议你不要这样做,这个工作最好还是让大师们去完成,原因是无锁算法没你想象的那么简单。
|
||||||
|
|
||||||
|
Java提供的原子类能够解决一些简单的原子性问题,但你可能会发现,上面我们所有原子类的方法都是针对一个共享变量的,如果你需要解决多个变量的原子性问题,建议还是使用互斥锁方案。原子类虽好,但使用要慎之又慎。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
下面的示例代码是合理库存的原子化实现,仅实现了设置库存上限setUpper()方法,你觉得setUpper()方法的实现是否正确呢?
|
||||||
|
|
||||||
|
public class SafeWM {
|
||||||
|
class WMRange{
|
||||||
|
final int upper;
|
||||||
|
final int lower;
|
||||||
|
WMRange(int upper,int lower){
|
||||||
|
//省略构造函数实现
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final AtomicReference<WMRange>
|
||||||
|
rf = new AtomicReference<>(
|
||||||
|
new WMRange(0,0)
|
||||||
|
);
|
||||||
|
// 设置库存上限
|
||||||
|
void setUpper(int v){
|
||||||
|
WMRange nr;
|
||||||
|
WMRange or = rf.get();
|
||||||
|
do{
|
||||||
|
// 检查参数合法性
|
||||||
|
if(v < or.lower){
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
nr = new
|
||||||
|
WMRange(v, or.lower);
|
||||||
|
}while(!rf.compareAndSet(or, nr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
166
专栏/Java并发编程实战/22Executor与线程池:如何创建正确的线程池?.md
Normal file
166
专栏/Java并发编程实战/22Executor与线程池:如何创建正确的线程池?.md
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
22 Executor与线程池:如何创建正确的线程池?
|
||||||
|
虽然在Java语言中创建线程看上去就像创建一个对象一样简单,只需要new Thread()就可以了,但实际上创建线程远不是创建一个对象那么简单。创建对象,仅仅是在JVM的堆里分配一块内存而已;而创建一个线程,却需要调用操作系统内核的API,然后操作系统要为线程分配一系列的资源,这个成本就很高了,所以线程是一个重量级的对象,应该避免频繁创建和销毁。
|
||||||
|
|
||||||
|
那如何避免呢?应对方案估计你已经知道了,那就是线程池。
|
||||||
|
|
||||||
|
线程池的需求是如此普遍,所以Java SDK并发包自然也少不了它。但是很多人在初次接触并发包里线程池相关的工具类时,多少会都有点蒙,不知道该从哪里入手,我觉得根本原因在于线程池和一般意义上的池化资源是不同的。一般意义上的池化资源,都是下面这样,当你需要资源的时候就调用acquire()方法来申请资源,用完之后就调用release()释放资源。若你带着这个固有模型来看并发包里线程池相关的工具类时,会很遗憾地发现它们完全匹配不上,Java提供的线程池里面压根就没有申请线程和释放线程的方法。
|
||||||
|
|
||||||
|
class XXXPool{
|
||||||
|
// 获取池化资源
|
||||||
|
XXX acquire() {
|
||||||
|
}
|
||||||
|
// 释放池化资源
|
||||||
|
void release(XXX x){
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
线程池是一种生产者-消费者模式
|
||||||
|
|
||||||
|
为什么线程池没有采用一般意义上池化资源的设计方法呢?如果线程池采用一般意义上池化资源的设计方法,应该是下面示例代码这样。你可以来思考一下,假设我们获取到一个空闲线程T1,然后该如何使用T1呢?你期望的可能是这样:通过调用T1的execute()方法,传入一个Runnable对象来执行具体业务逻辑,就像通过构造函数Thread(Runnable target)创建线程一样。可惜的是,你翻遍Thread对象的所有方法,都不存在类似execute(Runnable target)这样的公共方法。
|
||||||
|
|
||||||
|
//采用一般意义上池化资源的设计方法
|
||||||
|
class ThreadPool{
|
||||||
|
// 获取空闲线程
|
||||||
|
Thread acquire() {
|
||||||
|
}
|
||||||
|
// 释放线程
|
||||||
|
void release(Thread t){
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//期望的使用
|
||||||
|
ThreadPool pool;
|
||||||
|
Thread T1=pool.acquire();
|
||||||
|
//传入Runnable对象
|
||||||
|
T1.execute(()->{
|
||||||
|
//具体业务逻辑
|
||||||
|
......
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
所以,线程池的设计,没有办法直接采用一般意义上池化资源的设计方法。那线程池该如何设计呢?目前业界线程池的设计,普遍采用的都是生产者-消费者模式。线程池的使用方是生产者,线程池本身是消费者。在下面的示例代码中,我们创建了一个非常简单的线程池MyThreadPool,你可以通过它来理解线程池的工作原理。
|
||||||
|
|
||||||
|
//简化的线程池,仅用来说明工作原理
|
||||||
|
class MyThreadPool{
|
||||||
|
//利用阻塞队列实现生产者-消费者模式
|
||||||
|
BlockingQueue<Runnable> workQueue;
|
||||||
|
//保存内部工作线程
|
||||||
|
List<WorkerThread> threads
|
||||||
|
= new ArrayList<>();
|
||||||
|
// 构造方法
|
||||||
|
MyThreadPool(int poolSize,
|
||||||
|
BlockingQueue<Runnable> workQueue){
|
||||||
|
this.workQueue = workQueue;
|
||||||
|
// 创建工作线程
|
||||||
|
for(int idx=0; idx<poolSize; idx++){
|
||||||
|
WorkerThread work = new WorkerThread();
|
||||||
|
work.start();
|
||||||
|
threads.add(work);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 提交任务
|
||||||
|
void execute(Runnable command){
|
||||||
|
workQueue.put(command);
|
||||||
|
}
|
||||||
|
// 工作线程负责消费任务,并执行任务
|
||||||
|
class WorkerThread extends Thread{
|
||||||
|
public void run() {
|
||||||
|
//循环取任务并执行
|
||||||
|
while(true){ ①
|
||||||
|
Runnable task = workQueue.take();
|
||||||
|
task.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下面是使用示例 **/
|
||||||
|
// 创建有界阻塞队列
|
||||||
|
BlockingQueue<Runnable> workQueue =
|
||||||
|
new LinkedBlockingQueue<>(2);
|
||||||
|
// 创建线程池
|
||||||
|
MyThreadPool pool = new MyThreadPool(
|
||||||
|
10, workQueue);
|
||||||
|
// 提交任务
|
||||||
|
pool.execute(()->{
|
||||||
|
System.out.println("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
在MyThreadPool的内部,我们维护了一个阻塞队列workQueue和一组工作线程,工作线程的个数由构造函数中的poolSize来指定。用户通过调用execute()方法来提交Runnable任务,execute()方法的内部实现仅仅是将任务加入到workQueue中。MyThreadPool内部维护的工作线程会消费workQueue中的任务并执行任务,相关的代码就是代码①处的while循环。线程池主要的工作原理就这些,是不是还挺简单的?
|
||||||
|
|
||||||
|
如何使用Java中的线程池
|
||||||
|
|
||||||
|
Java并发包里提供的线程池,远比我们上面的示例代码强大得多,当然也复杂得多。Java提供的线程池相关的工具类中,最核心的是ThreadPoolExecutor,通过名字你也能看出来,它强调的是Executor,而不是一般意义上的池化资源。
|
||||||
|
|
||||||
|
ThreadPoolExecutor的构造函数非常复杂,如下面代码所示,这个最完备的构造函数有7个参数。
|
||||||
|
|
||||||
|
ThreadPoolExecutor(
|
||||||
|
int corePoolSize,
|
||||||
|
int maximumPoolSize,
|
||||||
|
long keepAliveTime,
|
||||||
|
TimeUnit unit,
|
||||||
|
BlockingQueue<Runnable> workQueue,
|
||||||
|
ThreadFactory threadFactory,
|
||||||
|
RejectedExecutionHandler handler)
|
||||||
|
|
||||||
|
|
||||||
|
下面我们一一介绍这些参数的意义,你可以把线程池类比为一个项目组,而线程就是项目组的成员。
|
||||||
|
|
||||||
|
|
||||||
|
corePoolSize:表示线程池保有的最小线程数。有些项目很闲,但是也不能把人都撤了,至少要留corePoolSize个人坚守阵地。
|
||||||
|
maximumPoolSize:表示线程池创建的最大线程数。当项目很忙时,就需要加人,但是也不能无限制地加,最多就加到maximumPoolSize个人。当项目闲下来时,就要撤人了,最多能撤到corePoolSize个人。
|
||||||
|
keepAliveTime & unit:上面提到项目根据忙闲来增减人员,那在编程世界里,如何定义忙和闲呢?很简单,一个线程如果在一段时间内,都没有执行任务,说明很闲,keepAliveTime 和 unit 就是用来定义这个“一段时间”的参数。也就是说,如果一个线程空闲了keepAliveTime & unit这么久,而且线程池的线程数大于 corePoolSize ,那么这个空闲的线程就要被回收了。
|
||||||
|
workQueue:工作队列,和上面示例代码的工作队列同义。
|
||||||
|
threadFactory:通过这个参数你可以自定义如何创建线程,例如你可以给线程指定一个有意义的名字。
|
||||||
|
handler:通过这个参数你可以自定义任务的拒绝策略。如果线程池中所有的线程都在忙碌,并且工作队列也满了(前提是工作队列是有界队列),那么此时提交任务,线程池就会拒绝接收。至于拒绝的策略,你可以通过handler这个参数来指定。ThreadPoolExecutor已经提供了以下4种策略。
|
||||||
|
|
||||||
|
|
||||||
|
CallerRunsPolicy:提交任务的线程自己去执行该任务。
|
||||||
|
AbortPolicy:默认的拒绝策略,会throws RejectedExecutionException。
|
||||||
|
DiscardPolicy:直接丢弃任务,没有任何异常抛出。
|
||||||
|
DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Java在1.6版本还增加了 allowCoreThreadTimeOut(boolean value) 方法,它可以让所有线程都支持超时,这意味着如果项目很闲,就会将项目组的成员都撤走。
|
||||||
|
|
||||||
|
使用线程池要注意些什么
|
||||||
|
|
||||||
|
考虑到ThreadPoolExecutor的构造函数实在是有些复杂,所以Java并发包里提供了一个线程池的静态工厂类Executors,利用Executors你可以快速创建线程池。不过目前大厂的编码规范中基本上都不建议使用Executors了,所以这里我就不再花篇幅介绍了。
|
||||||
|
|
||||||
|
不建议使用Executors的最重要的原因是:Executors提供的很多方法默认使用的都是无界的LinkedBlockingQueue,高负载情境下,无界队列很容易导致OOM,而OOM会导致所有请求都无法处理,这是致命问题。所以强烈建议使用有界队列。
|
||||||
|
|
||||||
|
使用有界队列,当任务过多时,线程池会触发执行拒绝策略,线程池默认的拒绝策略会throw RejectedExecutionException 这是个运行时异常,对于运行时异常编译器并不强制catch它,所以开发人员很容易忽略。因此默认拒绝策略要慎重使用。如果线程池处理的任务非常重要,建议自定义自己的拒绝策略;并且在实际工作中,自定义的拒绝策略往往和降级策略配合使用。
|
||||||
|
|
||||||
|
使用线程池,还要注意异常处理的问题,例如通过ThreadPoolExecutor对象的execute()方法提交任务时,如果任务在执行的过程中出现运行时异常,会导致执行任务的线程终止;不过,最致命的是任务虽然异常了,但是你却获取不到任何通知,这会让你误以为任务都执行得很正常。虽然线程池提供了很多用于异常处理的方法,但是最稳妥和简单的方案还是捕获所有异常并按需处理,你可以参考下面的示例代码。
|
||||||
|
|
||||||
|
try {
|
||||||
|
//业务逻辑
|
||||||
|
} catch (RuntimeException x) {
|
||||||
|
//按需处理
|
||||||
|
} catch (Throwable x) {
|
||||||
|
//按需处理
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
线程池在Java并发编程领域非常重要,很多大厂的编码规范都要求必须通过线程池来管理线程。线程池和普通的池化资源有很大不同,线程池实际上是生产者-消费者模式的一种实现,理解生产者-消费者模式是理解线程池的关键所在。
|
||||||
|
|
||||||
|
创建线程池设置合适的线程数非常重要,这部分内容,你可以参考《10 | Java线程(中):创建多少线程才是合适的?》的内容。另外《Java并发编程实战》的第7章《取消与关闭》的7.3节“处理非正常的线程终止” 详细介绍了异常处理的方案,第8章《线程池的使用》对线程池的使用也有更深入的介绍,如果你感兴趣或有需要的话,建议你仔细阅读。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
使用线程池,默认情况下创建的线程名字都类似pool-1-thread-2这样,没有业务含义。而很多情况下为了便于诊断问题,都需要给线程赋予一个有意义的名字,那你知道有哪些办法可以给线程池里的线程指定名字吗?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
220
专栏/Java并发编程实战/23Future:如何用多线程实现最优的“烧水泡茶”程序?.md
Normal file
220
专栏/Java并发编程实战/23Future:如何用多线程实现最优的“烧水泡茶”程序?.md
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
23 Future:如何用多线程实现最优的“烧水泡茶”程序?
|
||||||
|
在上一篇文章《22 | Executor与线程池:如何创建正确的线程池?》中,我们详细介绍了如何创建正确的线程池,那创建完线程池,我们该如何使用呢?在上一篇文章中,我们仅仅介绍了ThreadPoolExecutor的 void execute(Runnable command) 方法,利用这个方法虽然可以提交任务,但是却没有办法获取任务的执行结果(execute()方法没有返回值)。而很多场景下,我们又都是需要获取任务的执行结果的。那ThreadPoolExecutor是否提供了相关功能呢?必须的,这么重要的功能当然需要提供了。
|
||||||
|
|
||||||
|
下面我们就来介绍一下使用ThreadPoolExecutor的时候,如何获取任务执行结果。
|
||||||
|
|
||||||
|
如何获取任务执行结果
|
||||||
|
|
||||||
|
Java通过ThreadPoolExecutor提供的3个submit()方法和1个FutureTask工具类来支持获得任务执行结果的需求。下面我们先来介绍这3个submit()方法,这3个方法的方法签名如下。
|
||||||
|
|
||||||
|
// 提交Runnable任务
|
||||||
|
Future<?>
|
||||||
|
submit(Runnable task);
|
||||||
|
// 提交Callable任务
|
||||||
|
<T> Future<T>
|
||||||
|
submit(Callable<T> task);
|
||||||
|
// 提交Runnable任务及结果引用
|
||||||
|
<T> Future<T>
|
||||||
|
submit(Runnable task, T result);
|
||||||
|
|
||||||
|
|
||||||
|
你会发现它们的返回值都是Future接口,Future接口有5个方法,我都列在下面了,它们分别是取消任务的方法cancel()、判断任务是否已取消的方法isCancelled()、判断任务是否已结束的方法isDone()以及2个获得任务执行结果的get()和get(timeout, unit),其中最后一个get(timeout, unit)支持超时机制。通过Future接口的这5个方法你会发现,我们提交的任务不但能够获取任务执行结果,还可以取消任务。不过需要注意的是:这两个get()方法都是阻塞式的,如果被调用的时候,任务还没有执行完,那么调用get()方法的线程会阻塞,直到任务执行完才会被唤醒。
|
||||||
|
|
||||||
|
// 取消任务
|
||||||
|
boolean cancel(
|
||||||
|
boolean mayInterruptIfRunning);
|
||||||
|
// 判断任务是否已取消
|
||||||
|
boolean isCancelled();
|
||||||
|
// 判断任务是否已结束
|
||||||
|
boolean isDone();
|
||||||
|
// 获得任务执行结果
|
||||||
|
get();
|
||||||
|
// 获得任务执行结果,支持超时
|
||||||
|
get(long timeout, TimeUnit unit);
|
||||||
|
|
||||||
|
|
||||||
|
这3个submit()方法之间的区别在于方法参数不同,下面我们简要介绍一下。
|
||||||
|
|
||||||
|
|
||||||
|
提交Runnable任务 submit(Runnable task) :这个方法的参数是一个Runnable接口,Runnable接口的run()方法是没有返回值的,所以 submit(Runnable task) 这个方法返回的Future仅可以用来断言任务已经结束了,类似于Thread.join()。
|
||||||
|
|
||||||
|
提交Callable任务 submit(Callable<T> task):这个方法的参数是一个Callable接口,它只有一个call()方法,并且这个方法是有返回值的,所以这个方法返回的Future对象可以通过调用其get()方法来获取任务的执行结果。
|
||||||
|
|
||||||
|
提交Runnable任务及结果引用 submit(Runnable task, T result):这个方法很有意思,假设这个方法返回的Future对象是f,f.get()的返回值就是传给submit()方法的参数result。这个方法该怎么用呢?下面这段示例代码展示了它的经典用法。需要你注意的是Runnable接口的实现类Task声明了一个有参构造函数 Task(Result r) ,创建Task对象的时候传入了result对象,这样就能在类Task的run()方法中对result进行各种操作了。result相当于主线程和子线程之间的桥梁,通过它主子线程可以共享数据。
|
||||||
|
|
||||||
|
ExecutorService executor
|
||||||
|
= Executors.newFixedThreadPool(1);
|
||||||
|
// 创建Result对象r
|
||||||
|
Result r = new Result();
|
||||||
|
r.setAAA(a);
|
||||||
|
// 提交任务
|
||||||
|
Future future =
|
||||||
|
executor.submit(new Task®, r);
|
||||||
|
Result fr = future.get();
|
||||||
|
// 下面等式成立
|
||||||
|
fr === r;
|
||||||
|
fr.getAAA() === a;
|
||||||
|
fr.getXXX() === x
|
||||||
|
|
||||||
|
class Task implements Runnable{
|
||||||
|
Result r;
|
||||||
|
//通过构造函数传入result
|
||||||
|
Task(Result r){
|
||||||
|
|
||||||
|
this.r = r;
|
||||||
|
|
||||||
|
}
|
||||||
|
void run() {
|
||||||
|
|
||||||
|
//可以操作result
|
||||||
|
a = r.getAAA();
|
||||||
|
r.setXXX(x);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
下面我们再来介绍FutureTask工具类。前面我们提到的Future是一个接口,而FutureTask是一个实实在在的工具类,这个工具类有两个构造函数,它们的参数和前面介绍的submit()方法类似,所以这里我就不再赘述了。
|
||||||
|
|
||||||
|
FutureTask(Callable<V> callable);
|
||||||
|
FutureTask(Runnable runnable, V result);
|
||||||
|
|
||||||
|
|
||||||
|
那如何使用FutureTask呢?其实很简单,FutureTask实现了Runnable和Future接口,由于实现了Runnable接口,所以可以将FutureTask对象作为任务提交给ThreadPoolExecutor去执行,也可以直接被Thread执行;又因为实现了Future接口,所以也能用来获得任务的执行结果。下面的示例代码是将FutureTask对象提交给ThreadPoolExecutor去执行。
|
||||||
|
|
||||||
|
// 创建FutureTask
|
||||||
|
FutureTask<Integer> futureTask
|
||||||
|
= new FutureTask<>(()-> 1+2);
|
||||||
|
// 创建线程池
|
||||||
|
ExecutorService es =
|
||||||
|
Executors.newCachedThreadPool();
|
||||||
|
// 提交FutureTask
|
||||||
|
es.submit(futureTask);
|
||||||
|
// 获取计算结果
|
||||||
|
Integer result = futureTask.get();
|
||||||
|
|
||||||
|
|
||||||
|
FutureTask对象直接被Thread执行的示例代码如下所示。相信你已经发现了,利用FutureTask对象可以很容易获取子线程的执行结果。
|
||||||
|
|
||||||
|
// 创建FutureTask
|
||||||
|
FutureTask<Integer> futureTask
|
||||||
|
= new FutureTask<>(()-> 1+2);
|
||||||
|
// 创建并启动线程
|
||||||
|
Thread T1 = new Thread(futureTask);
|
||||||
|
T1.start();
|
||||||
|
// 获取计算结果
|
||||||
|
Integer result = futureTask.get();
|
||||||
|
|
||||||
|
|
||||||
|
实现最优的“烧水泡茶”程序
|
||||||
|
|
||||||
|
记得以前初中语文课文里有一篇著名数学家华罗庚先生的文章《统筹方法》,这篇文章里介绍了一个烧水泡茶的例子,文中提到最优的工序应该是下面这样:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
烧水泡茶最优工序
|
||||||
|
|
||||||
|
下面我们用程序来模拟一下这个最优工序。我们专栏前面曾经提到,并发编程可以总结为三个核心问题:分工、同步和互斥。编写并发程序,首先要做的就是分工,所谓分工指的是如何高效地拆解任务并分配给线程。对于烧水泡茶这个程序,一种最优的分工方案可以是下图所示的这样:用两个线程T1和T2来完成烧水泡茶程序,T1负责洗水壶、烧开水、泡茶这三道工序,T2负责洗茶壶、洗茶杯、拿茶叶三道工序,其中T1在执行泡茶这道工序时需要等待T2完成拿茶叶的工序。对于T1的这个等待动作,你应该可以想出很多种办法,例如Thread.join()、CountDownLatch,甚至阻塞队列都可以解决,不过今天我们用Future特性来实现。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
烧水泡茶最优分工方案
|
||||||
|
|
||||||
|
下面的示例代码就是用这一章提到的Future特性来实现的。首先,我们创建了两个FutureTask——ft1和ft2,ft1完成洗水壶、烧开水、泡茶的任务,ft2完成洗茶壶、洗茶杯、拿茶叶的任务;这里需要注意的是ft1这个任务在执行泡茶任务前,需要等待ft2把茶叶拿来,所以ft1内部需要引用ft2,并在执行泡茶之前,调用ft2的get()方法实现等待。
|
||||||
|
|
||||||
|
// 创建任务T2的FutureTask
|
||||||
|
FutureTask<String> ft2
|
||||||
|
= new FutureTask<>(new T2Task());
|
||||||
|
// 创建任务T1的FutureTask
|
||||||
|
FutureTask<String> ft1
|
||||||
|
= new FutureTask<>(new T1Task(ft2));
|
||||||
|
// 线程T1执行任务ft1
|
||||||
|
Thread T1 = new Thread(ft1);
|
||||||
|
T1.start();
|
||||||
|
// 线程T2执行任务ft2
|
||||||
|
Thread T2 = new Thread(ft2);
|
||||||
|
T2.start();
|
||||||
|
// 等待线程T1执行结果
|
||||||
|
System.out.println(ft1.get());
|
||||||
|
|
||||||
|
// T1Task需要执行的任务:
|
||||||
|
// 洗水壶、烧开水、泡茶
|
||||||
|
class T1Task implements Callable<String>{
|
||||||
|
FutureTask<String> ft2;
|
||||||
|
// T1任务需要T2任务的FutureTask
|
||||||
|
T1Task(FutureTask<String> ft2){
|
||||||
|
this.ft2 = ft2;
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
String call() throws Exception {
|
||||||
|
System.out.println("T1:洗水壶...");
|
||||||
|
TimeUnit.SECONDS.sleep(1);
|
||||||
|
|
||||||
|
System.out.println("T1:烧开水...");
|
||||||
|
TimeUnit.SECONDS.sleep(15);
|
||||||
|
// 获取T2线程的茶叶
|
||||||
|
String tf = ft2.get();
|
||||||
|
System.out.println("T1:拿到茶叶:"+tf);
|
||||||
|
|
||||||
|
System.out.println("T1:泡茶...");
|
||||||
|
return "上茶:" + tf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// T2Task需要执行的任务:
|
||||||
|
// 洗茶壶、洗茶杯、拿茶叶
|
||||||
|
class T2Task implements Callable<String> {
|
||||||
|
@Override
|
||||||
|
String call() throws Exception {
|
||||||
|
System.out.println("T2:洗茶壶...");
|
||||||
|
TimeUnit.SECONDS.sleep(1);
|
||||||
|
|
||||||
|
System.out.println("T2:洗茶杯...");
|
||||||
|
TimeUnit.SECONDS.sleep(2);
|
||||||
|
|
||||||
|
System.out.println("T2:拿茶叶...");
|
||||||
|
TimeUnit.SECONDS.sleep(1);
|
||||||
|
return "龙井";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 一次执行结果:
|
||||||
|
T1:洗水壶...
|
||||||
|
T2:洗茶壶...
|
||||||
|
T1:烧开水...
|
||||||
|
T2:洗茶杯...
|
||||||
|
T2:拿茶叶...
|
||||||
|
T1:拿到茶叶:龙井
|
||||||
|
T1:泡茶...
|
||||||
|
上茶:龙井
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
利用Java并发包提供的Future可以很容易获得异步任务的执行结果,无论异步任务是通过线程池ThreadPoolExecutor执行的,还是通过手工创建子线程来执行的。Future可以类比为现实世界里的提货单,比如去蛋糕店订生日蛋糕,蛋糕店都是先给你一张提货单,你拿到提货单之后,没有必要一直在店里等着,可以先去干点其他事,比如看场电影;等看完电影后,基本上蛋糕也做好了,然后你就可以凭提货单领蛋糕了。
|
||||||
|
|
||||||
|
利用多线程可以快速将一些串行的任务并行化,从而提高性能;如果任务之间有依赖关系,比如当前任务依赖前一个任务的执行结果,这种问题基本上都可以用Future来解决。在分析这种问题的过程中,建议你用有向图描述一下任务之间的依赖关系,同时将线程的分工也做好,类似于烧水泡茶最优分工方案那幅图。对照图来写代码,好处是更形象,且不易出错。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
不久前听说小明要做一个询价应用,这个应用需要从三个电商询价,然后保存在自己的数据库里。核心示例代码如下所示,由于是串行的,所以性能很慢,你来试着优化一下吧。
|
||||||
|
|
||||||
|
// 向电商S1询价,并保存
|
||||||
|
r1 = getPriceByS1();
|
||||||
|
save(r1);
|
||||||
|
// 向电商S2询价,并保存
|
||||||
|
r2 = getPriceByS2();
|
||||||
|
save(r2);
|
||||||
|
// 向电商S3询价,并保存
|
||||||
|
r3 = getPriceByS3();
|
||||||
|
save(r3);
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
279
专栏/Java并发编程实战/24CompletableFuture:异步编程没那么难.md
Normal file
279
专栏/Java并发编程实战/24CompletableFuture:异步编程没那么难.md
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
24 CompletableFuture:异步编程没那么难
|
||||||
|
前面我们不止一次提到,用多线程优化性能,其实不过就是将串行操作变成并行操作。如果仔细观察,你还会发现在串行转换成并行的过程中,一定会涉及到异步化,例如下面的示例代码,现在是串行的,为了提升性能,我们得把它们并行化,那具体实施起来该怎么做呢?
|
||||||
|
|
||||||
|
//以下两个方法都是耗时操作
|
||||||
|
doBizA();
|
||||||
|
doBizB();
|
||||||
|
|
||||||
|
|
||||||
|
还是挺简单的,就像下面代码中这样,创建两个子线程去执行就可以了。你会发现下面的并行方案,主线程无需等待doBizA()和doBizB()的执行结果,也就是说doBizA()和doBizB()两个操作已经被异步化了。
|
||||||
|
|
||||||
|
new Thread(()->doBizA())
|
||||||
|
.start();
|
||||||
|
new Thread(()->doBizB())
|
||||||
|
.start();
|
||||||
|
|
||||||
|
|
||||||
|
异步化,是并行方案得以实施的基础,更深入地讲其实就是:利用多线程优化性能这个核心方案得以实施的基础。看到这里,相信你应该就能理解异步编程最近几年为什么会大火了,因为优化性能是互联网大厂的一个核心需求啊。Java在1.8版本提供了CompletableFuture来支持异步编程,CompletableFuture有可能是你见过的最复杂的工具类了,不过功能也着实让人感到震撼。
|
||||||
|
|
||||||
|
CompletableFuture的核心优势
|
||||||
|
|
||||||
|
为了领略CompletableFuture异步编程的优势,这里我们用CompletableFuture重新实现前面曾提及的烧水泡茶程序。首先还是需要先完成分工方案,在下面的程序中,我们分了3个任务:任务1负责洗水壶、烧开水,任务2负责洗茶壶、洗茶杯和拿茶叶,任务3负责泡茶。其中任务3要等待任务1和任务2都完成后才能开始。这个分工如下图所示。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
烧水泡茶分工方案
|
||||||
|
|
||||||
|
下面是代码实现,你先略过runAsync()、supplyAsync()、thenCombine()这些不太熟悉的方法,从大局上看,你会发现:
|
||||||
|
|
||||||
|
|
||||||
|
无需手工维护线程,没有繁琐的手工维护线程的工作,给任务分配线程的工作也不需要我们关注;
|
||||||
|
|
||||||
|
语义更清晰,例如 f3 = f1.thenCombine(f2, ()->{}) 能够清晰地表述“任务3要等待任务1和任务2都完成后才能开始”;
|
||||||
|
|
||||||
|
代码更简练并且专注于业务逻辑,几乎所有代码都是业务逻辑相关的。
|
||||||
|
|
||||||
|
//任务1:洗水壶->烧开水
|
||||||
|
CompletableFuture f1 =
|
||||||
|
CompletableFuture.runAsync(()->{
|
||||||
|
System.out.println(“T1:洗水壶…”);
|
||||||
|
sleep(1, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
System.out.println(“T1:烧开水…”);
|
||||||
|
sleep(15, TimeUnit.SECONDS);
|
||||||
|
});
|
||||||
|
//任务2:洗茶壶->洗茶杯->拿茶叶
|
||||||
|
CompletableFuture f2 =
|
||||||
|
CompletableFuture.supplyAsync(()->{
|
||||||
|
System.out.println(“T2:洗茶壶…”);
|
||||||
|
sleep(1, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
System.out.println(“T2:洗茶杯…”);
|
||||||
|
sleep(2, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
System.out.println(“T2:拿茶叶…”);
|
||||||
|
sleep(1, TimeUnit.SECONDS);
|
||||||
|
return “龙井”;
|
||||||
|
});
|
||||||
|
//任务3:任务1和任务2完成后执行:泡茶
|
||||||
|
CompletableFuture f3 =
|
||||||
|
f1.thenCombine(f2, (__, tf)->{
|
||||||
|
|
||||||
|
System.out.println("T1:拿到茶叶:" + tf);
|
||||||
|
System.out.println("T1:泡茶...");
|
||||||
|
return "上茶:" + tf;
|
||||||
|
|
||||||
|
});
|
||||||
|
//等待任务3执行结果
|
||||||
|
System.out.println(f3.join());
|
||||||
|
|
||||||
|
void sleep(int t, TimeUnit u) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
u.sleep(t);
|
||||||
|
|
||||||
|
}catch(InterruptedException e){}
|
||||||
|
}
|
||||||
|
// 一次执行结果:
|
||||||
|
T1:洗水壶…
|
||||||
|
T2:洗茶壶…
|
||||||
|
T1:烧开水…
|
||||||
|
T2:洗茶杯…
|
||||||
|
T2:拿茶叶…
|
||||||
|
T1:拿到茶叶:龙井
|
||||||
|
T1:泡茶…
|
||||||
|
上茶:龙井
|
||||||
|
|
||||||
|
|
||||||
|
领略CompletableFuture异步编程的优势之后,下面我们详细介绍CompletableFuture的使用,首先是如何创建CompletableFuture对象。
|
||||||
|
|
||||||
|
创建CompletableFuture对象
|
||||||
|
|
||||||
|
创建CompletableFuture对象主要靠下面代码中展示的这4个静态方法,我们先看前两个。在烧水泡茶的例子中,我们已经使用了runAsync(Runnable runnable)和supplyAsync(Supplier<U> supplier),它们之间的区别是:Runnable 接口的run()方法没有返回值,而Supplier接口的get()方法是有返回值的。
|
||||||
|
|
||||||
|
前两个方法和后两个方法的区别在于:后两个方法可以指定线程池参数。
|
||||||
|
|
||||||
|
默认情况下CompletableFuture会使用公共的ForkJoinPool线程池,这个线程池默认创建的线程数是CPU的核数(也可以通过JVM option:-Djava.util.concurrent.ForkJoinPool.common.parallelism来设置ForkJoinPool线程池的线程数)。如果所有CompletableFuture共享一个线程池,那么一旦有任务执行一些很慢的I/O操作,就会导致线程池中所有线程都阻塞在I/O操作上,从而造成线程饥饿,进而影响整个系统的性能。所以,强烈建议你要根据不同的业务类型创建不同的线程池,以避免互相干扰。
|
||||||
|
|
||||||
|
//使用默认线程池
|
||||||
|
static CompletableFuture<Void>
|
||||||
|
runAsync(Runnable runnable)
|
||||||
|
static <U> CompletableFuture<U>
|
||||||
|
supplyAsync(Supplier<U> supplier)
|
||||||
|
//可以指定线程池
|
||||||
|
static CompletableFuture<Void>
|
||||||
|
runAsync(Runnable runnable, Executor executor)
|
||||||
|
static <U> CompletableFuture<U>
|
||||||
|
supplyAsync(Supplier<U> supplier, Executor executor)
|
||||||
|
|
||||||
|
|
||||||
|
创建完CompletableFuture对象之后,会自动地异步执行runnable.run()方法或者supplier.get()方法,对于一个异步操作,你需要关注两个问题:一个是异步操作什么时候结束,另一个是如何获取异步操作的执行结果。因为CompletableFuture类实现了Future接口,所以这两个问题你都可以通过Future接口来解决。另外,CompletableFuture类还实现了CompletionStage接口,这个接口内容实在是太丰富了,在1.8版本里有40个方法,这些方法我们该如何理解呢?
|
||||||
|
|
||||||
|
如何理解CompletionStage接口
|
||||||
|
|
||||||
|
我觉得,你可以站在分工的角度类比一下工作流。任务是有时序关系的,比如有串行关系、并行关系、汇聚关系等。这样说可能有点抽象,这里还举前面烧水泡茶的例子,其中洗水壶和烧开水就是串行关系,洗水壶、烧开水和洗茶壶、洗茶杯这两组任务之间就是并行关系,而烧开水、拿茶叶和泡茶就是汇聚关系。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
串行关系
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
并行关系
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
汇聚关系
|
||||||
|
|
||||||
|
CompletionStage接口可以清晰地描述任务之间的这种时序关系,例如前面提到的 f3 = f1.thenCombine(f2, ()->{}) 描述的就是一种汇聚关系。烧水泡茶程序中的汇聚关系是一种 AND 聚合关系,这里的AND指的是所有依赖的任务(烧开水和拿茶叶)都完成后才开始执行当前任务(泡茶)。既然有AND聚合关系,那就一定还有OR聚合关系,所谓OR指的是依赖的任务只要有一个完成就可以执行当前任务。
|
||||||
|
|
||||||
|
在编程领域,还有一个绕不过去的山头,那就是异常处理,CompletionStage接口也可以方便地描述异常处理。
|
||||||
|
|
||||||
|
下面我们就来一一介绍,CompletionStage接口如何描述串行关系、AND聚合关系、OR聚合关系以及异常处理。
|
||||||
|
|
||||||
|
1. 描述串行关系
|
||||||
|
|
||||||
|
CompletionStage接口里面描述串行关系,主要是thenApply、thenAccept、thenRun和thenCompose这四个系列的接口。
|
||||||
|
|
||||||
|
thenApply系列函数里参数fn的类型是接口Function,这个接口里与CompletionStage相关的方法是 R apply(T t),这个方法既能接收参数也支持返回值,所以thenApply系列方法返回的是CompletionStage<R>。
|
||||||
|
|
||||||
|
而thenAccept系列方法里参数consumer的类型是接口Consumer<T>,这个接口里与CompletionStage相关的方法是 void accept(T t),这个方法虽然支持参数,但却不支持回值,所以thenAccept系列方法返回的是CompletionStage<Void>。
|
||||||
|
|
||||||
|
thenRun系列方法里action的参数是Runnable,所以action既不能接收参数也不支持返回值,所以thenRun系列方法返回的也是CompletionStage<Void>。
|
||||||
|
|
||||||
|
这些方法里面Async代表的是异步执行fn、consumer或者action。其中,需要你注意的是thenCompose系列方法,这个系列的方法会新创建出一个子流程,最终结果和thenApply系列是相同的。
|
||||||
|
|
||||||
|
CompletionStage<R> thenApply(fn);
|
||||||
|
CompletionStage<R> thenApplyAsync(fn);
|
||||||
|
CompletionStage<Void> thenAccept(consumer);
|
||||||
|
CompletionStage<Void> thenAcceptAsync(consumer);
|
||||||
|
CompletionStage<Void> thenRun(action);
|
||||||
|
CompletionStage<Void> thenRunAsync(action);
|
||||||
|
CompletionStage<R> thenCompose(fn);
|
||||||
|
CompletionStage<R> thenComposeAsync(fn);
|
||||||
|
|
||||||
|
|
||||||
|
通过下面的示例代码,你可以看一下thenApply()方法是如何使用的。首先通过supplyAsync()启动一个异步流程,之后是两个串行操作,整体看起来还是挺简单的。不过,虽然这是一个异步流程,但任务①②③却是串行执行的,②依赖①的执行结果,③依赖②的执行结果。
|
||||||
|
|
||||||
|
CompletableFuture<String> f0 =
|
||||||
|
CompletableFuture.supplyAsync(
|
||||||
|
() -> "Hello World") //①
|
||||||
|
.thenApply(s -> s + " QQ") //②
|
||||||
|
.thenApply(String::toUpperCase);//③
|
||||||
|
|
||||||
|
System.out.println(f0.join());
|
||||||
|
//输出结果
|
||||||
|
HELLO WORLD QQ
|
||||||
|
|
||||||
|
|
||||||
|
2. 描述AND汇聚关系
|
||||||
|
|
||||||
|
CompletionStage接口里面描述AND汇聚关系,主要是thenCombine、thenAcceptBoth和runAfterBoth系列的接口,这些接口的区别也是源自fn、consumer、action这三个核心参数不同。它们的使用你可以参考上面烧水泡茶的实现程序,这里就不赘述了。
|
||||||
|
|
||||||
|
CompletionStage<R> thenCombine(other, fn);
|
||||||
|
CompletionStage<R> thenCombineAsync(other, fn);
|
||||||
|
CompletionStage<Void> thenAcceptBoth(other, consumer);
|
||||||
|
CompletionStage<Void> thenAcceptBothAsync(other, consumer);
|
||||||
|
CompletionStage<Void> runAfterBoth(other, action);
|
||||||
|
CompletionStage<Void> runAfterBothAsync(other, action);
|
||||||
|
|
||||||
|
|
||||||
|
3. 描述OR汇聚关系
|
||||||
|
|
||||||
|
CompletionStage接口里面描述OR汇聚关系,主要是applyToEither、acceptEither和runAfterEither系列的接口,这些接口的区别也是源自fn、consumer、action这三个核心参数不同。
|
||||||
|
|
||||||
|
CompletionStage applyToEither(other, fn);
|
||||||
|
CompletionStage applyToEitherAsync(other, fn);
|
||||||
|
CompletionStage acceptEither(other, consumer);
|
||||||
|
CompletionStage acceptEitherAsync(other, consumer);
|
||||||
|
CompletionStage runAfterEither(other, action);
|
||||||
|
CompletionStage runAfterEitherAsync(other, action);
|
||||||
|
|
||||||
|
|
||||||
|
下面的示例代码展示了如何使用applyToEither()方法来描述一个OR汇聚关系。
|
||||||
|
|
||||||
|
CompletableFuture<String> f1 =
|
||||||
|
CompletableFuture.supplyAsync(()->{
|
||||||
|
int t = getRandom(5, 10);
|
||||||
|
sleep(t, TimeUnit.SECONDS);
|
||||||
|
return String.valueOf(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
CompletableFuture<String> f2 =
|
||||||
|
CompletableFuture.supplyAsync(()->{
|
||||||
|
int t = getRandom(5, 10);
|
||||||
|
sleep(t, TimeUnit.SECONDS);
|
||||||
|
return String.valueOf(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
CompletableFuture<String> f3 =
|
||||||
|
f1.applyToEither(f2,s -> s);
|
||||||
|
|
||||||
|
System.out.println(f3.join());
|
||||||
|
|
||||||
|
|
||||||
|
4. 异常处理
|
||||||
|
|
||||||
|
虽然上面我们提到的fn、consumer、action它们的核心方法都不允许抛出可检查异常,但是却无法限制它们抛出运行时异常,例如下面的代码,执行 7/0 就会出现除零错误这个运行时异常。非异步编程里面,我们可以使用try{}catch{}来捕获并处理异常,那在异步编程里面,异常该如何处理呢?
|
||||||
|
|
||||||
|
CompletableFuture<Integer>
|
||||||
|
f0 = CompletableFuture.
|
||||||
|
.supplyAsync(()->(7/0))
|
||||||
|
.thenApply(r->r*10);
|
||||||
|
System.out.println(f0.join());
|
||||||
|
|
||||||
|
|
||||||
|
CompletionStage接口给我们提供的方案非常简单,比try{}catch{}还要简单,下面是相关的方法,使用这些方法进行异常处理和串行操作是一样的,都支持链式编程方式。
|
||||||
|
|
||||||
|
CompletionStage exceptionally(fn);
|
||||||
|
CompletionStage<R> whenComplete(consumer);
|
||||||
|
CompletionStage<R> whenCompleteAsync(consumer);
|
||||||
|
CompletionStage<R> handle(fn);
|
||||||
|
CompletionStage<R> handleAsync(fn);
|
||||||
|
|
||||||
|
|
||||||
|
下面的示例代码展示了如何使用exceptionally()方法来处理异常,exceptionally()的使用非常类似于try{}catch{}中的catch{},但是由于支持链式编程方式,所以相对更简单。既然有try{}catch{},那就一定还有try{}finally{},whenComplete()和handle()系列方法就类似于try{}finally{}中的finally{},无论是否发生异常都会执行whenComplete()中的回调函数consumer和handle()中的回调函数fn。whenComplete()和handle()的区别在于whenComplete()不支持返回结果,而handle()是支持返回结果的。
|
||||||
|
|
||||||
|
CompletableFuture<Integer>
|
||||||
|
f0 = CompletableFuture
|
||||||
|
.supplyAsync(()->(7/0))
|
||||||
|
.thenApply(r->r*10)
|
||||||
|
.exceptionally(e->0);
|
||||||
|
System.out.println(f0.join());
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
曾经一提到异步编程,大家脑海里都会随之浮现回调函数,例如在JavaScript里面异步问题基本上都是靠回调函数来解决的,回调函数在处理异常以及复杂的异步任务关系时往往力不从心,对此业界还发明了个名词:回调地狱(Callback Hell)。应该说在前些年,异步编程还是声名狼藉的。
|
||||||
|
|
||||||
|
不过最近几年,伴随着ReactiveX的发展(Java语言的实现版本是RxJava),回调地狱已经被完美解决了,异步编程已经慢慢开始成熟,Java语言也开始官方支持异步编程:在1.8版本提供了CompletableFuture,在Java 9版本则提供了更加完备的Flow API,异步编程目前已经完全工业化。因此,学好异步编程还是很有必要的。
|
||||||
|
|
||||||
|
CompletableFuture已经能够满足简单的异步编程需求,如果你对异步编程感兴趣,可以重点关注RxJava这个项目,利用RxJava,即便在Java 1.6版本也能享受异步编程的乐趣。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
创建采购订单的时候,需要校验一些规则,例如最大金额是和采购员级别相关的。有同学利用CompletableFuture实现了这个校验的功能,逻辑很简单,首先是从数据库中把相关规则查出来,然后执行规则校验。你觉得他的实现是否有问题呢?
|
||||||
|
|
||||||
|
//采购订单
|
||||||
|
PurchersOrder po;
|
||||||
|
CompletableFuture<Boolean> cf =
|
||||||
|
CompletableFuture.supplyAsync(()->{
|
||||||
|
//在数据库中查询规则
|
||||||
|
return findRuleByJdbc();
|
||||||
|
}).thenApply(r -> {
|
||||||
|
//规则校验
|
||||||
|
return check(po, r);
|
||||||
|
});
|
||||||
|
Boolean isOk = cf.join();
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
212
专栏/Java并发编程实战/25CompletionService:如何批量执行异步任务?.md
Normal file
212
专栏/Java并发编程实战/25CompletionService:如何批量执行异步任务?.md
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
25 CompletionService:如何批量执行异步任务?
|
||||||
|
在《23 | Future:如何用多线程实现最优的“烧水泡茶”程序?》的最后,我给你留了道思考题,如何优化一个询价应用的核心代码?如果采用“ThreadPoolExecutor+Future”的方案,你的优化结果很可能是下面示例代码这样:用三个线程异步执行询价,通过三次调用Future的get()方法获取询价结果,之后将询价结果保存在数据库中。
|
||||||
|
|
||||||
|
// 创建线程池
|
||||||
|
ExecutorService executor =
|
||||||
|
Executors.newFixedThreadPool(3);
|
||||||
|
// 异步向电商S1询价
|
||||||
|
Future<Integer> f1 =
|
||||||
|
executor.submit(
|
||||||
|
()->getPriceByS1());
|
||||||
|
// 异步向电商S2询价
|
||||||
|
Future<Integer> f2 =
|
||||||
|
executor.submit(
|
||||||
|
()->getPriceByS2());
|
||||||
|
// 异步向电商S3询价
|
||||||
|
Future<Integer> f3 =
|
||||||
|
executor.submit(
|
||||||
|
()->getPriceByS3());
|
||||||
|
|
||||||
|
// 获取电商S1报价并保存
|
||||||
|
r=f1.get();
|
||||||
|
executor.execute(()->save(r));
|
||||||
|
|
||||||
|
// 获取电商S2报价并保存
|
||||||
|
r=f2.get();
|
||||||
|
executor.execute(()->save(r));
|
||||||
|
|
||||||
|
// 获取电商S3报价并保存
|
||||||
|
r=f3.get();
|
||||||
|
executor.execute(()->save(r));
|
||||||
|
|
||||||
|
|
||||||
|
上面的这个方案本身没有太大问题,但是有个地方的处理需要你注意,那就是如果获取电商S1报价的耗时很长,那么即便获取电商S2报价的耗时很短,也无法让保存S2报价的操作先执行,因为这个主线程都阻塞在了 f1.get() 操作上。这点小瑕疵你该如何解决呢?
|
||||||
|
|
||||||
|
估计你已经想到了,增加一个阻塞队列,获取到S1、S2、S3的报价都进入阻塞队列,然后在主线程中消费阻塞队列,这样就能保证先获取到的报价先保存到数据库了。下面的示例代码展示了如何利用阻塞队列实现先获取到的报价先保存到数据库。
|
||||||
|
|
||||||
|
// 创建阻塞队列
|
||||||
|
BlockingQueue<Integer> bq =
|
||||||
|
new LinkedBlockingQueue<>();
|
||||||
|
//电商S1报价异步进入阻塞队列
|
||||||
|
executor.execute(()->
|
||||||
|
bq.put(f1.get()));
|
||||||
|
//电商S2报价异步进入阻塞队列
|
||||||
|
executor.execute(()->
|
||||||
|
bq.put(f2.get()));
|
||||||
|
//电商S3报价异步进入阻塞队列
|
||||||
|
executor.execute(()->
|
||||||
|
bq.put(f3.get()));
|
||||||
|
//异步保存所有报价
|
||||||
|
for (int i=0; i<3; i++) {
|
||||||
|
Integer r = bq.take();
|
||||||
|
executor.execute(()->save(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
利用CompletionService实现询价系统
|
||||||
|
|
||||||
|
不过在实际项目中,并不建议你这样做,因为Java SDK并发包里已经提供了设计精良的CompletionService。利用CompletionService不但能帮你解决先获取到的报价先保存到数据库的问题,而且还能让代码更简练。
|
||||||
|
|
||||||
|
CompletionService的实现原理也是内部维护了一个阻塞队列,当任务执行结束就把任务的执行结果加入到阻塞队列中,不同的是CompletionService是把任务执行结果的Future对象加入到阻塞队列中,而上面的示例代码是把任务最终的执行结果放入了阻塞队列中。
|
||||||
|
|
||||||
|
那到底该如何创建CompletionService呢?
|
||||||
|
|
||||||
|
CompletionService接口的实现类是ExecutorCompletionService,这个实现类的构造方法有两个,分别是:
|
||||||
|
|
||||||
|
|
||||||
|
ExecutorCompletionService(Executor executor);
|
||||||
|
ExecutorCompletionService(Executor executor, BlockingQueue<Future<V>> completionQueue)。
|
||||||
|
|
||||||
|
|
||||||
|
这两个构造方法都需要传入一个线程池,如果不指定completionQueue,那么默认会使用无界的LinkedBlockingQueue。任务执行结果的Future对象就是加入到completionQueue中。
|
||||||
|
|
||||||
|
下面的示例代码完整地展示了如何利用CompletionService来实现高性能的询价系统。其中,我们没有指定completionQueue,因此默认使用无界的LinkedBlockingQueue。之后通过CompletionService接口提供的submit()方法提交了三个询价操作,这三个询价操作将会被CompletionService异步执行。最后,我们通过CompletionService接口提供的take()方法获取一个Future对象(前面我们提到过,加入到阻塞队列中的是任务执行结果的Future对象),调用Future对象的get()方法就能返回询价操作的执行结果了。
|
||||||
|
|
||||||
|
// 创建线程池
|
||||||
|
ExecutorService executor =
|
||||||
|
Executors.newFixedThreadPool(3);
|
||||||
|
// 创建CompletionService
|
||||||
|
CompletionService<Integer> cs = new
|
||||||
|
ExecutorCompletionService<>(executor);
|
||||||
|
// 异步向电商S1询价
|
||||||
|
cs.submit(()->getPriceByS1());
|
||||||
|
// 异步向电商S2询价
|
||||||
|
cs.submit(()->getPriceByS2());
|
||||||
|
// 异步向电商S3询价
|
||||||
|
cs.submit(()->getPriceByS3());
|
||||||
|
// 将询价结果异步保存到数据库
|
||||||
|
for (int i=0; i<3; i++) {
|
||||||
|
Integer r = cs.take().get();
|
||||||
|
executor.execute(()->save(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CompletionService接口说明
|
||||||
|
|
||||||
|
下面我们详细地介绍一下CompletionService接口提供的方法,CompletionService接口提供的方法有5个,这5个方法的方法签名如下所示。
|
||||||
|
|
||||||
|
其中,submit()相关的方法有两个。一个方法参数是Callable<V> task,前面利用CompletionService实现询价系统的示例代码中,我们提交任务就是用的它。另外一个方法有两个参数,分别是Runnable task和V result,这个方法类似于ThreadPoolExecutor的 <T> Future<T> submit(Runnable task, T result) ,这个方法在《23 | Future:如何用多线程实现最优的“烧水泡茶”程序?》中我们已详细介绍过,这里不再赘述。
|
||||||
|
|
||||||
|
CompletionService接口其余的3个方法,都是和阻塞队列相关的,take()、poll()都是从阻塞队列中获取并移除一个元素;它们的区别在于如果阻塞队列是空的,那么调用 take() 方法的线程会被阻塞,而 poll() 方法会返回 null 值。 poll(long timeout, TimeUnit unit) 方法支持以超时的方式获取并移除阻塞队列头部的一个元素,如果等待了 timeout unit时间,阻塞队列还是空的,那么该方法会返回 null 值。
|
||||||
|
|
||||||
|
Future<V> submit(Callable<V> task);
|
||||||
|
Future<V> submit(Runnable task, V result);
|
||||||
|
Future<V> take()
|
||||||
|
throws InterruptedException;
|
||||||
|
Future<V> poll();
|
||||||
|
Future<V> poll(long timeout, TimeUnit unit)
|
||||||
|
throws InterruptedException;
|
||||||
|
|
||||||
|
|
||||||
|
利用CompletionService实现Dubbo中的Forking Cluster
|
||||||
|
|
||||||
|
Dubbo中有一种叫做Forking的集群模式,这种集群模式下,支持并行地调用多个查询服务,只要有一个成功返回结果,整个服务就可以返回了。例如你需要提供一个地址转坐标的服务,为了保证该服务的高可用和性能,你可以并行地调用3个地图服务商的API,然后只要有1个正确返回了结果r,那么地址转坐标这个服务就可以直接返回r了。这种集群模式可以容忍2个地图服务商服务异常,但缺点是消耗的资源偏多。
|
||||||
|
|
||||||
|
geocoder(addr) {
|
||||||
|
//并行执行以下3个查询服务,
|
||||||
|
r1=geocoderByS1(addr);
|
||||||
|
r2=geocoderByS2(addr);
|
||||||
|
r3=geocoderByS3(addr);
|
||||||
|
//只要r1,r2,r3有一个返回
|
||||||
|
//则返回
|
||||||
|
return r1|r2|r3;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
利用CompletionService可以快速实现 Forking 这种集群模式,比如下面的示例代码就展示了具体是如何实现的。首先我们创建了一个线程池executor 、一个CompletionService对象cs和一个Future<Integer>类型的列表 futures,每次通过调用CompletionService的submit()方法提交一个异步任务,会返回一个Future对象,我们把这些Future对象保存在列表futures中。通过调用 cs.take().get(),我们能够拿到最快返回的任务执行结果,只要我们拿到一个正确返回的结果,就可以取消所有任务并且返回最终结果了。
|
||||||
|
|
||||||
|
// 创建线程池
|
||||||
|
ExecutorService executor =
|
||||||
|
Executors.newFixedThreadPool(3);
|
||||||
|
// 创建CompletionService
|
||||||
|
CompletionService<Integer> cs =
|
||||||
|
new ExecutorCompletionService<>(executor);
|
||||||
|
// 用于保存Future对象
|
||||||
|
List<Future<Integer>> futures =
|
||||||
|
new ArrayList<>(3);
|
||||||
|
//提交异步任务,并保存future到futures
|
||||||
|
futures.add(
|
||||||
|
cs.submit(()->geocoderByS1()));
|
||||||
|
futures.add(
|
||||||
|
cs.submit(()->geocoderByS2()));
|
||||||
|
futures.add(
|
||||||
|
cs.submit(()->geocoderByS3()));
|
||||||
|
// 获取最快返回的任务执行结果
|
||||||
|
Integer r = 0;
|
||||||
|
try {
|
||||||
|
// 只要有一个成功返回,则break
|
||||||
|
for (int i = 0; i < 3; ++i) {
|
||||||
|
r = cs.take().get();
|
||||||
|
//简单地通过判空来检查是否成功返回
|
||||||
|
if (r != null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
//取消所有任务
|
||||||
|
for(Future<Integer> f : futures)
|
||||||
|
f.cancel(true);
|
||||||
|
}
|
||||||
|
// 返回结果
|
||||||
|
return r;
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
当需要批量提交异步任务的时候建议你使用CompletionService。CompletionService将线程池Executor和阻塞队列BlockingQueue的功能融合在了一起,能够让批量异步任务的管理更简单。除此之外,CompletionService能够让异步任务的执行结果有序化,先执行完的先进入阻塞队列,利用这个特性,你可以轻松实现后续处理的有序性,避免无谓的等待,同时还可以快速实现诸如Forking Cluster这样的需求。
|
||||||
|
|
||||||
|
CompletionService的实现类ExecutorCompletionService,需要你自己创建线程池,虽看上去有些啰嗦,但好处是你可以让多个ExecutorCompletionService的线程池隔离,这种隔离性能避免几个特别耗时的任务拖垮整个应用的风险。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
本章使用CompletionService实现了一个询价应用的核心功能,后来又有了新的需求,需要计算出最低报价并返回,下面的示例代码尝试实现这个需求,你看看是否存在问题呢?
|
||||||
|
|
||||||
|
// 创建线程池
|
||||||
|
ExecutorService executor =
|
||||||
|
Executors.newFixedThreadPool(3);
|
||||||
|
// 创建CompletionService
|
||||||
|
CompletionService<Integer> cs = new
|
||||||
|
ExecutorCompletionService<>(executor);
|
||||||
|
// 异步向电商S1询价
|
||||||
|
cs.submit(()->getPriceByS1());
|
||||||
|
// 异步向电商S2询价
|
||||||
|
cs.submit(()->getPriceByS2());
|
||||||
|
// 异步向电商S3询价
|
||||||
|
cs.submit(()->getPriceByS3());
|
||||||
|
// 将询价结果异步保存到数据库
|
||||||
|
// 并计算最低报价
|
||||||
|
AtomicReference<Integer> m =
|
||||||
|
new AtomicReference<>(Integer.MAX_VALUE);
|
||||||
|
for (int i=0; i<3; i++) {
|
||||||
|
executor.execute(()->{
|
||||||
|
Integer r = null;
|
||||||
|
try {
|
||||||
|
r = cs.take().get();
|
||||||
|
} catch (Exception e) {}
|
||||||
|
save(r);
|
||||||
|
m.set(Integer.min(m.get(), r));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
196
专栏/Java并发编程实战/26Fork_Join:单机版的MapReduce.md
Normal file
196
专栏/Java并发编程实战/26Fork_Join:单机版的MapReduce.md
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
26 Fork_Join:单机版的MapReduce
|
||||||
|
前面几篇文章我们介绍了线程池、Future、CompletableFuture和CompletionService,仔细观察你会发现这些工具类都是在帮助我们站在任务的视角来解决并发问题,而不是让我们纠缠在线程之间如何协作的细节上(比如线程之间如何实现等待、通知等)。对于简单的并行任务,你可以通过“线程池+Future”的方案来解决;如果任务之间有聚合关系,无论是AND聚合还是OR聚合,都可以通过CompletableFuture来解决;而批量的并行任务,则可以通过CompletionService来解决。
|
||||||
|
|
||||||
|
我们一直讲,并发编程可以分为三个层面的问题,分别是分工、协作和互斥,当你关注于任务的时候,你会发现你的视角已经从并发编程的细节中跳出来了,你应用的更多的是现实世界的思维模式,类比的往往是现实世界里的分工,所以我把线程池、Future、CompletableFuture和CompletionService都列到了分工里面。
|
||||||
|
|
||||||
|
下面我用现实世界里的工作流程图描述了并发编程领域的简单并行任务、聚合任务和批量并行任务,辅以这些流程图,相信你一定能将你的思维模式转换到现实世界里来。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
从上到下,依次为简单并行任务、聚合任务和批量并行任务示意图
|
||||||
|
|
||||||
|
上面提到的简单并行、聚合、批量并行这三种任务模型,基本上能够覆盖日常工作中的并发场景了,但还是不够全面,因为还有一种“分治”的任务模型没有覆盖到。分治,顾名思义,即分而治之,是一种解决复杂问题的思维方法和模式;具体来讲,指的是把一个复杂的问题分解成多个相似的子问题,然后再把子问题分解成更小的子问题,直到子问题简单到可以直接求解。理论上来讲,解决每一个问题都对应着一个任务,所以对于问题的分治,实际上就是对于任务的分治。
|
||||||
|
|
||||||
|
分治思想在很多领域都有广泛的应用,例如算法领域有分治算法(归并排序、快速排序都属于分治算法,二分法查找也是一种分治算法);大数据领域知名的计算框架MapReduce背后的思想也是分治。既然分治这种任务模型如此普遍,那Java显然也需要支持,Java并发包里提供了一种叫做Fork/Join的并行计算框架,就是用来支持分治这种任务模型的。
|
||||||
|
|
||||||
|
分治任务模型
|
||||||
|
|
||||||
|
这里你需要先深入了解一下分治任务模型,分治任务模型可分为两个阶段:一个阶段是任务分解,也就是将任务迭代地分解为子任务,直至子任务可以直接计算出结果;另一个阶段是结果合并,即逐层合并子任务的执行结果,直至获得最终结果。下图是一个简化的分治任务模型图,你可以对照着理解。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
简版分治任务模型图
|
||||||
|
|
||||||
|
在这个分治任务模型里,任务和分解后的子任务具有相似性,这种相似性往往体现在任务和子任务的算法是相同的,但是计算的数据规模是不同的。具备这种相似性的问题,我们往往都采用递归算法。
|
||||||
|
|
||||||
|
Fork/Join的使用
|
||||||
|
|
||||||
|
Fork/Join是一个并行计算的框架,主要就是用来支持分治任务模型的,这个计算框架里的Fork对应的是分治任务模型里的任务分解,Join对应的是结果合并。Fork/Join计算框架主要包含两部分,一部分是分治任务的线程池ForkJoinPool,另一部分是分治任务ForkJoinTask。这两部分的关系类似于ThreadPoolExecutor和Runnable的关系,都可以理解为提交任务到线程池,只不过分治任务有自己独特类型ForkJoinTask。
|
||||||
|
|
||||||
|
ForkJoinTask是一个抽象类,它的方法有很多,最核心的是fork()方法和join()方法,其中fork()方法会异步地执行一个子任务,而join()方法则会阻塞当前线程来等待子任务的执行结果。ForkJoinTask有两个子类——RecursiveAction和RecursiveTask,通过名字你就应该能知道,它们都是用递归的方式来处理分治任务的。这两个子类都定义了抽象方法compute(),不过区别是RecursiveAction定义的compute()没有返回值,而RecursiveTask定义的compute()方法是有返回值的。这两个子类也是抽象类,在使用的时候,需要你定义子类去扩展。
|
||||||
|
|
||||||
|
接下来我们就来实现一下,看看如何用Fork/Join这个并行计算框架计算斐波那契数列(下面的代码源自Java官方示例)。首先我们需要创建一个分治任务线程池以及计算斐波那契数列的分治任务,之后通过调用分治任务线程池的 invoke() 方法来启动分治任务。由于计算斐波那契数列需要有返回值,所以Fibonacci 继承自RecursiveTask。分治任务Fibonacci 需要实现compute()方法,这个方法里面的逻辑和普通计算斐波那契数列非常类似,区别之处在于计算 Fibonacci(n - 1) 使用了异步子任务,这是通过 f1.fork() 这条语句实现的。
|
||||||
|
|
||||||
|
static void main(String[] args){
|
||||||
|
//创建分治任务线程池
|
||||||
|
ForkJoinPool fjp =
|
||||||
|
new ForkJoinPool(4);
|
||||||
|
//创建分治任务
|
||||||
|
Fibonacci fib =
|
||||||
|
new Fibonacci(30);
|
||||||
|
//启动分治任务
|
||||||
|
Integer result =
|
||||||
|
fjp.invoke(fib);
|
||||||
|
//输出结果
|
||||||
|
System.out.println(result);
|
||||||
|
}
|
||||||
|
//递归任务
|
||||||
|
static class Fibonacci extends
|
||||||
|
RecursiveTask<Integer>{
|
||||||
|
final int n;
|
||||||
|
Fibonacci(int n){this.n = n;}
|
||||||
|
protected Integer compute(){
|
||||||
|
if (n <= 1)
|
||||||
|
return n;
|
||||||
|
Fibonacci f1 =
|
||||||
|
new Fibonacci(n - 1);
|
||||||
|
//创建子任务
|
||||||
|
f1.fork();
|
||||||
|
Fibonacci f2 =
|
||||||
|
new Fibonacci(n - 2);
|
||||||
|
//等待子任务结果,并合并结果
|
||||||
|
return f2.compute() + f1.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ForkJoinPool工作原理
|
||||||
|
|
||||||
|
Fork/Join并行计算的核心组件是ForkJoinPool,所以下面我们就来简单介绍一下ForkJoinPool的工作原理。
|
||||||
|
|
||||||
|
通过专栏前面文章的学习,你应该已经知道ThreadPoolExecutor本质上是一个生产者-消费者模式的实现,内部有一个任务队列,这个任务队列是生产者和消费者通信的媒介;ThreadPoolExecutor可以有多个工作线程,但是这些工作线程都共享一个任务队列。
|
||||||
|
|
||||||
|
ForkJoinPool本质上也是一个生产者-消费者的实现,但是更加智能,你可以参考下面的ForkJoinPool工作原理图来理解其原理。ThreadPoolExecutor内部只有一个任务队列,而ForkJoinPool内部有多个任务队列,当我们通过ForkJoinPool的invoke()或者submit()方法提交任务时,ForkJoinPool根据一定的路由规则把任务提交到一个任务队列中,如果任务在执行过程中会创建出子任务,那么子任务会提交到工作线程对应的任务队列中。
|
||||||
|
|
||||||
|
如果工作线程对应的任务队列空了,是不是就没活儿干了呢?不是的,ForkJoinPool支持一种叫做“任务窃取”的机制,如果工作线程空闲了,那它可以“窃取”其他工作任务队列里的任务,例如下图中,线程T2对应的任务队列已经空了,它可以“窃取”线程T1对应的任务队列的任务。如此一来,所有的工作线程都不会闲下来了。
|
||||||
|
|
||||||
|
ForkJoinPool中的任务队列采用的是双端队列,工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费,这样能避免很多不必要的数据竞争。我们这里介绍的仅仅是简化后的原理,ForkJoinPool的实现远比我们这里介绍的复杂,如果你感兴趣,建议去看它的源码。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ForkJoinPool工作原理图
|
||||||
|
|
||||||
|
模拟MapReduce统计单词数量
|
||||||
|
|
||||||
|
学习MapReduce有一个入门程序,统计一个文件里面每个单词的数量,下面我们来看看如何用Fork/Join并行计算框架来实现。
|
||||||
|
|
||||||
|
我们可以先用二分法递归地将一个文件拆分成更小的文件,直到文件里只有一行数据,然后统计这一行数据里单词的数量,最后再逐级汇总结果,你可以对照前面的简版分治任务模型图来理解这个过程。
|
||||||
|
|
||||||
|
思路有了,我们马上来实现。下面的示例程序用一个字符串数组 String[] fc 来模拟文件内容,fc里面的元素与文件里面的行数据一一对应。关键的代码在 compute() 这个方法里面,这是一个递归方法,前半部分数据fork一个递归任务去处理(关键代码mr1.fork()),后半部分数据则在当前任务中递归处理(mr2.compute())。
|
||||||
|
|
||||||
|
static void main(String[] args){
|
||||||
|
String[] fc = {"hello world",
|
||||||
|
"hello me",
|
||||||
|
"hello fork",
|
||||||
|
"hello join",
|
||||||
|
"fork join in world"};
|
||||||
|
//创建ForkJoin线程池
|
||||||
|
ForkJoinPool fjp =
|
||||||
|
new ForkJoinPool(3);
|
||||||
|
//创建任务
|
||||||
|
MR mr = new MR(
|
||||||
|
fc, 0, fc.length);
|
||||||
|
//启动任务
|
||||||
|
Map<String, Long> result =
|
||||||
|
fjp.invoke(mr);
|
||||||
|
//输出结果
|
||||||
|
result.forEach((k, v)->
|
||||||
|
System.out.println(k+":"+v));
|
||||||
|
}
|
||||||
|
//MR模拟类
|
||||||
|
static class MR extends
|
||||||
|
RecursiveTask<Map<String, Long>> {
|
||||||
|
private String[] fc;
|
||||||
|
private int start, end;
|
||||||
|
//构造函数
|
||||||
|
MR(String[] fc, int fr, int to){
|
||||||
|
this.fc = fc;
|
||||||
|
this.start = fr;
|
||||||
|
this.end = to;
|
||||||
|
}
|
||||||
|
@Override protected
|
||||||
|
Map<String, Long> compute(){
|
||||||
|
if (end - start == 1) {
|
||||||
|
return calc(fc[start]);
|
||||||
|
} else {
|
||||||
|
int mid = (start+end)/2;
|
||||||
|
MR mr1 = new MR(
|
||||||
|
fc, start, mid);
|
||||||
|
mr1.fork();
|
||||||
|
MR mr2 = new MR(
|
||||||
|
fc, mid, end);
|
||||||
|
//计算子任务,并返回合并的结果
|
||||||
|
return merge(mr2.compute(),
|
||||||
|
mr1.join());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//合并结果
|
||||||
|
private Map<String, Long> merge(
|
||||||
|
Map<String, Long> r1,
|
||||||
|
Map<String, Long> r2) {
|
||||||
|
Map<String, Long> result =
|
||||||
|
new HashMap<>();
|
||||||
|
result.putAll(r1);
|
||||||
|
//合并结果
|
||||||
|
r2.forEach((k, v) -> {
|
||||||
|
Long c = result.get(k);
|
||||||
|
if (c != null)
|
||||||
|
result.put(k, c+v);
|
||||||
|
else
|
||||||
|
result.put(k, v);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
//统计单词数量
|
||||||
|
private Map<String, Long>
|
||||||
|
calc(String line) {
|
||||||
|
Map<String, Long> result =
|
||||||
|
new HashMap<>();
|
||||||
|
//分割单词
|
||||||
|
String [] words =
|
||||||
|
line.split("\\s+");
|
||||||
|
//统计单词数量
|
||||||
|
for (String w : words) {
|
||||||
|
Long v = result.get(w);
|
||||||
|
if (v != null)
|
||||||
|
result.put(w, v+1);
|
||||||
|
else
|
||||||
|
result.put(w, 1L);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Fork/Join并行计算框架主要解决的是分治任务。分治的核心思想是“分而治之”:将一个大的任务拆分成小的子任务去解决,然后再把子任务的结果聚合起来从而得到最终结果。这个过程非常类似于大数据处理中的MapReduce,所以你可以把Fork/Join看作单机版的MapReduce。
|
||||||
|
|
||||||
|
Fork/Join并行计算框架的核心组件是ForkJoinPool。ForkJoinPool支持任务窃取机制,能够让所有线程的工作量基本均衡,不会出现有的线程很忙,而有的线程很闲的状况,所以性能很好。Java 1.8提供的Stream API里面并行流也是以ForkJoinPool为基础的。不过需要你注意的是,默认情况下所有的并行流计算都共享一个ForkJoinPool,这个共享的ForkJoinPool默认的线程数是CPU的核数;如果所有的并行流计算都是CPU密集型计算的话,完全没有问题,但是如果存在I/O密集型的并行流计算,那么很可能会因为一个很慢的I/O计算而拖慢整个系统的性能。所以建议用不同的ForkJoinPool执行不同类型的计算任务。
|
||||||
|
|
||||||
|
如果你对ForkJoinPool详细的实现细节感兴趣,也可以参考Doug Lea的论文。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
对于一个CPU密集型计算程序,在单核CPU上,使用Fork/Join并行计算框架是否能够提高性能呢?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
220
专栏/Java并发编程实战/27并发工具类模块热点问题答疑.md
Normal file
220
专栏/Java并发编程实战/27并发工具类模块热点问题答疑.md
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
27 并发工具类模块热点问题答疑
|
||||||
|
前面我们用13篇文章的内容介绍了Java SDK提供的并发工具类,这些工具类都是久经考验的,所以学好用好它们对于解决并发问题非常重要。我们在介绍这些工具类的时候,重点介绍了这些工具类的产生背景、应用场景以及实现原理,目的就是让你在面对并发问题的时候,有思路,有办法。只有思路、办法有了,才谈得上开始动手解决问题。
|
||||||
|
|
||||||
|
当然了,只有思路和办法还不足以把问题解决,最终还是要动手实践的,我觉得在实践中有两方面的问题需要重点关注:细节问题与最佳实践。千里之堤毁于蚁穴,细节虽然不能保证成功,但是可以导致失败,所以我们一直都强调要关注细节。而最佳实践是前人的经验总结,可以帮助我们不要阴沟里翻船,所以没有十足的理由,一定要遵守。
|
||||||
|
|
||||||
|
为了让你学完即学即用,我在每篇文章的最后都给你留了道思考题。这13篇文章的13个思考题,基本上都是相关工具类在使用中需要特别注意的一些细节问题,工作中容易碰到且费神费力,所以咱们今天就来一一分析。
|
||||||
|
|
||||||
|
|
||||||
|
while(true) 总不让人省心
|
||||||
|
———————-
|
||||||
|
|
||||||
|
|
||||||
|
《14 | Lock&Condition(上):隐藏在并发包中的管程》的思考题,本意是通过破坏不可抢占条件来避免死锁问题,但是它的实现中有一个致命的问题,那就是: while(true) 没有break条件,从而导致了死循环。除此之外,这个实现虽然不存在死锁问题,但还是存在活锁问题的,解决活锁问题很简单,只需要随机等待一小段时间就可以了。
|
||||||
|
|
||||||
|
修复后的代码如下所示,我仅仅修改了两个地方,一处是转账成功之后break,另一处是在while循环体结束前增加了Thread.sleep(随机时间)。
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
private int balance;
|
||||||
|
private final Lock lock
|
||||||
|
= new ReentrantLock();
|
||||||
|
// 转账
|
||||||
|
void transfer(Account tar, int amt){
|
||||||
|
while (true) {
|
||||||
|
if(this.lock.tryLock()) {
|
||||||
|
try {
|
||||||
|
if (tar.lock.tryLock()) {
|
||||||
|
try {
|
||||||
|
this.balance -= amt;
|
||||||
|
tar.balance += amt;
|
||||||
|
//新增:退出循环
|
||||||
|
break;
|
||||||
|
} finally {
|
||||||
|
tar.lock.unlock();
|
||||||
|
}
|
||||||
|
}//if
|
||||||
|
} finally {
|
||||||
|
this.lock.unlock();
|
||||||
|
}
|
||||||
|
}//if
|
||||||
|
//新增:sleep一个随机时间避免活锁
|
||||||
|
Thread.sleep(随机时间);
|
||||||
|
}//while
|
||||||
|
}//transfer
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这个思考题里面的while(true)问题还是比较容易看出来的,但不是所有的while(true)问题都这么显而易见的,很多都隐藏得比较深。
|
||||||
|
|
||||||
|
例如,《21 | 原子类:无锁工具类的典范》的思考题本质上也是一个while(true),不过它隐藏得就比较深了。看上去 while(!rf.compareAndSet(or, nr)) 是有终止条件的,而且跑单线程测试一直都没有问题。实际上却存在严重的并发问题,问题就出在对or的赋值在while循环之外,这样每次循环or的值都不会发生变化,所以一旦有一次循环rf.compareAndSet(or, nr)的值等于false,那之后无论循环多少次,都会等于false。也就是说在特定场景下,变成了while(true)问题。既然找到了原因,修改就很简单了,只要把对or的赋值移到while循环之内就可以了,修改后的代码如下所示:
|
||||||
|
|
||||||
|
public class SafeWM {
|
||||||
|
class WMRange{
|
||||||
|
final int upper;
|
||||||
|
final int lower;
|
||||||
|
WMRange(int upper,int lower){
|
||||||
|
//省略构造函数实现
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final AtomicReference<WMRange>
|
||||||
|
rf = new AtomicReference<>(
|
||||||
|
new WMRange(0,0)
|
||||||
|
);
|
||||||
|
// 设置库存上限
|
||||||
|
void setUpper(int v){
|
||||||
|
WMRange nr;
|
||||||
|
WMRange or;
|
||||||
|
//原代码在这里
|
||||||
|
//WMRange or=rf.get();
|
||||||
|
do{
|
||||||
|
//移动到此处
|
||||||
|
//每个回合都需要重新获取旧值
|
||||||
|
or = rf.get();
|
||||||
|
// 检查参数合法性
|
||||||
|
if(v < or.lower){
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
nr = new
|
||||||
|
WMRange(v, or.lower);
|
||||||
|
}while(!rf.compareAndSet(or, nr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
signalAll() 总让人省心
|
||||||
|
———————
|
||||||
|
|
||||||
|
|
||||||
|
《15 | Lock&Condition(下):Dubbo如何用管程实现异步转同步?》的思考题是关于signal()和signalAll()的,Dubbo最近已经把signal()改成signalAll()了,我觉得用signal()也不能说错,但的确是用signalAll()会更安全。我个人也倾向于使用signalAll(),因为我们写程序,不是做数学题,而是在搞工程,工程中会有很多不稳定的因素,更有很多你预料不到的情况发生,所以不要让你的代码铤而走险,尽量使用更稳妥的方案和设计。Dubbo修改后的相关代码如下所示:
|
||||||
|
|
||||||
|
// RPC结果返回时调用该方法
|
||||||
|
private void doReceived(Response res) {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
response = res;
|
||||||
|
done.signalAll();
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Semaphore需要锁中锁
|
||||||
|
——————
|
||||||
|
|
||||||
|
|
||||||
|
《16 | Semaphore:如何快速实现一个限流器?》的思考题是对象池的例子中Vector能否换成ArrayList,答案是不可以的。Semaphore可以允许多个线程访问一个临界区,那就意味着可能存在多个线程同时访问ArrayList,而ArrayList不是线程安全的,所以对象池的例子中是不能够将Vector换成ArrayList的。Semaphore允许多个线程访问一个临界区,这也是一把双刃剑,当多个线程进入临界区时,如果需要访问共享变量就会存在并发问题,所以必须加锁,也就是说Semaphore需要锁中锁。
|
||||||
|
|
||||||
|
|
||||||
|
锁的申请和释放要成对出现
|
||||||
|
—————-
|
||||||
|
|
||||||
|
|
||||||
|
《18 | StampedLock:有没有比读写锁更快的锁?》思考题的Bug出在没有正确地释放锁。锁的申请和释放要成对出现,对此我们有一个最佳实践,就是使用try{}finally{},但是try{}finally{}并不能解决所有锁的释放问题。比如示例代码中,锁的升级会生成新的stamp ,而finally中释放锁用的是锁升级前的stamp,本质上这也属于锁的申请和释放没有成对出现,只是它隐藏得有点深。解决这个问题倒也很简单,只需要对stamp 重新赋值就可以了,修复后的代码如下所示:
|
||||||
|
|
||||||
|
private double x, y;
|
||||||
|
final StampedLock sl = new StampedLock();
|
||||||
|
// 存在问题的方法
|
||||||
|
void moveIfAtOrigin(double newX, double newY){
|
||||||
|
long stamp = sl.readLock();
|
||||||
|
try {
|
||||||
|
while(x == 0.0 && y == 0.0){
|
||||||
|
long ws = sl.tryConvertToWriteLock(stamp);
|
||||||
|
if (ws != 0L) {
|
||||||
|
//问题出在没有对stamp重新赋值
|
||||||
|
//新增下面一行
|
||||||
|
stamp = ws;
|
||||||
|
x = newX;
|
||||||
|
y = newY;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
sl.unlockRead(stamp);
|
||||||
|
stamp = sl.writeLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
//此处unlock的是stamp
|
||||||
|
sl.unlock(stamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
回调总要关心执行线程是谁
|
||||||
|
—————-
|
||||||
|
|
||||||
|
|
||||||
|
《19 | CountDownLatch和CyclicBarrier:如何让多线程步调一致?》的思考题是:CyclicBarrier的回调函数使用了一个固定大小为1的线程池,是否合理?我觉得是合理的,可以从以下两个方面来分析。
|
||||||
|
|
||||||
|
第一个是线程池大小是1,只有1个线程,主要原因是check()方法的耗时比getPOrders()和getDOrders()都要短,所以没必要用多个线程,同时单线程能保证访问的数据不存在并发问题。
|
||||||
|
|
||||||
|
第二个是使用了线程池,如果不使用,直接在回调函数里调用check()方法是否可以呢?绝对不可以。为什么呢?这个要分析一下回调函数和唤醒等待线程之间的关系。下面是CyclicBarrier相关的源码,通过源码你会发现CyclicBarrier是同步调用回调函数之后才唤醒等待的线程,如果我们在回调函数里直接调用check()方法,那就意味着在执行check()的时候,是不能同时执行getPOrders()和getDOrders()的,这样就起不到提升性能的作用。
|
||||||
|
|
||||||
|
try {
|
||||||
|
//barrierCommand是回调函数
|
||||||
|
final Runnable command = barrierCommand;
|
||||||
|
//调用回调函数
|
||||||
|
if (command != null)
|
||||||
|
command.run();
|
||||||
|
ranAction = true;
|
||||||
|
//唤醒等待的线程
|
||||||
|
nextGeneration();
|
||||||
|
return 0;
|
||||||
|
} finally {
|
||||||
|
if (!ranAction)
|
||||||
|
breakBarrier();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
所以,当遇到回调函数的时候,你应该本能地问自己:执行回调函数的线程是哪一个?这个在多线程场景下非常重要。因为不同线程ThreadLocal里的数据是不同的,有些框架比如Spring就用ThreadLocal来管理事务,如果不清楚回调函数用的是哪个线程,很可能会导致错误的事务管理,并最终导致数据不一致。
|
||||||
|
|
||||||
|
CyclicBarrier的回调函数究竟是哪个线程执行的呢?如果你分析源码,你会发现执行回调函数的线程是将CyclicBarrier内部计数器减到 0 的那个线程。所以我们前面讲执行check()的时候,是不能同时执行getPOrders()和getDOrders(),因为执行这两个方法的线程一个在等待,一个正在忙着执行check()。
|
||||||
|
|
||||||
|
再次强调一下:当看到回调函数的时候,一定问一问执行回调函数的线程是谁。
|
||||||
|
|
||||||
|
|
||||||
|
共享线程池:有福同享就要有难同当
|
||||||
|
——————–
|
||||||
|
|
||||||
|
|
||||||
|
《24 | CompletableFuture:异步编程没那么难》的思考题是下列代码是否有问题。很多同学都发现这段代码的问题了,例如没有异常处理、逻辑不严谨等等,不过我更想让你关注的是:findRuleByJdbc()这个方法隐藏着一个阻塞式I/O,这意味着会阻塞调用线程。默认情况下所有的CompletableFuture共享一个ForkJoinPool,当有阻塞式I/O时,可能导致所有的ForkJoinPool线程都阻塞,进而影响整个系统的性能。
|
||||||
|
|
||||||
|
//采购订单
|
||||||
|
PurchersOrder po;
|
||||||
|
CompletableFuture<Boolean> cf =
|
||||||
|
CompletableFuture.supplyAsync(()->{
|
||||||
|
//在数据库中查询规则
|
||||||
|
return findRuleByJdbc();
|
||||||
|
}).thenApply(r -> {
|
||||||
|
//规则校验
|
||||||
|
return check(po, r);
|
||||||
|
});
|
||||||
|
Boolean isOk = cf.join();
|
||||||
|
|
||||||
|
|
||||||
|
利用共享,往往能让我们快速实现功能,所谓是有福同享,但是代价就是有难要同当。在强调高可用的今天,大多数人更倾向于使用隔离的方案。
|
||||||
|
|
||||||
|
|
||||||
|
线上问题定位的利器:线程栈dump
|
||||||
|
———————
|
||||||
|
|
||||||
|
|
||||||
|
《17 | ReadWriteLock:如何快速实现一个完备的缓存?》和《20 | 并发容器:都有哪些“坑”需要我们填?》的思考题,本质上都是定位线上并发问题,方案很简单,就是通过查看线程栈来定位问题。重点是查看线程状态,分析线程进入该状态的原因是否合理,你可以参考《09 | Java线程(上):Java线程的生命周期》来加深理解。
|
||||||
|
|
||||||
|
为了便于分析定位线程问题,你需要给线程赋予一个有意义的名字,对于线程池可以通过自定义ThreadFactory来给线程池中的线程赋予有意义的名字,也可以在执行run()方法时通过Thread.currentThread().setName();来给线程赋予一个更贴近业务的名字。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Java并发工具类到今天为止,就告一段落了,由于篇幅原因,不能每个工具类都详细介绍。Java并发工具类内容繁杂,熟练使用是需要一个过程的,而且需要多加实践。希望你学完这个模块之后,遇到并发问题时最起码能知道用哪些工具可以解决。至于工具使用的细节和最佳实践,我总结的也只是我认为重要的。由于每个人的思维方式和编码习惯不同,也许我认为不重要的,恰恰是你的短板,所以这部分内容更多地还是需要你去实践,在实践中养成良好的编码习惯,不断纠正错误的思维方式。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
220
专栏/Java并发编程实战/28Immutability模式:如何利用不变性解决并发问题?.md
Normal file
220
专栏/Java并发编程实战/28Immutability模式:如何利用不变性解决并发问题?.md
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
28 Immutability模式:如何利用不变性解决并发问题?
|
||||||
|
我们曾经说过,“多个线程同时读写同一共享变量存在并发问题”,这里的必要条件之一是读写,如果只有读,而没有写,是没有并发问题的。
|
||||||
|
|
||||||
|
解决并发问题,其实最简单的办法就是让共享变量只有读操作,而没有写操作。这个办法如此重要,以至于被上升到了一种解决并发问题的设计模式:不变性(Immutability)模式。所谓不变性,简单来讲,就是对象一旦被创建之后,状态就不再发生变化。换句话说,就是变量一旦被赋值,就不允许修改了(没有写操作);没有修改操作,也就是保持了不变性。
|
||||||
|
|
||||||
|
快速实现具备不可变性的类
|
||||||
|
|
||||||
|
实现一个具备不可变性的类,还是挺简单的。将一个类所有的属性都设置成final的,并且只允许存在只读方法,那么这个类基本上就具备不可变性了。更严格的做法是这个类本身也是final的,也就是不允许继承。因为子类可以覆盖父类的方法,有可能改变不可变性,所以推荐你在实际工作中,使用这种更严格的做法。
|
||||||
|
|
||||||
|
Java SDK里很多类都具备不可变性,只是由于它们的使用太简单,最后反而被忽略了。例如经常用到的String和Long、Integer、Double等基础类型的包装类都具备不可变性,这些对象的线程安全性都是靠不可变性来保证的。如果你仔细翻看这些类的声明、属性和方法,你会发现它们都严格遵守不可变类的三点要求:类和属性都是final的,所有方法均是只读的。
|
||||||
|
|
||||||
|
看到这里你可能会疑惑,Java的String方法也有类似字符替换操作,怎么能说所有方法都是只读的呢?我们结合String的源代码来解释一下这个问题,下面的示例代码源自Java 1.8 SDK,我略做了修改,仅保留了关键属性value[]和replace()方法,你会发现:String这个类以及它的属性value[]都是final的;而replace()方法的实现,就的确没有修改value[],而是将替换后的字符串作为返回值返回了。
|
||||||
|
|
||||||
|
public final class String {
|
||||||
|
private final char value[];
|
||||||
|
// 字符替换
|
||||||
|
String replace(char oldChar,
|
||||||
|
char newChar) {
|
||||||
|
//无需替换,直接返回this
|
||||||
|
if (oldChar == newChar){
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
int len = value.length;
|
||||||
|
int i = -1;
|
||||||
|
/* avoid getfield opcode */
|
||||||
|
char[] val = value;
|
||||||
|
//定位到需要替换的字符位置
|
||||||
|
while (++i < len) {
|
||||||
|
if (val[i] == oldChar) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//未找到oldChar,无需替换
|
||||||
|
if (i >= len) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
//创建一个buf[],这是关键
|
||||||
|
//用来保存替换后的字符串
|
||||||
|
char buf[] = new char[len];
|
||||||
|
for (int j = 0; j < i; j++) {
|
||||||
|
buf[j] = val[j];
|
||||||
|
}
|
||||||
|
while (i < len) {
|
||||||
|
char c = val[i];
|
||||||
|
buf[i] = (c == oldChar) ?
|
||||||
|
newChar : c;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
//创建一个新的字符串返回
|
||||||
|
//原字符串不会发生任何变化
|
||||||
|
return new String(buf, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
通过分析String的实现,你可能已经发现了,如果具备不可变性的类,需要提供类似修改的功能,具体该怎么操作呢?做法很简单,那就是创建一个新的不可变对象,这是与可变对象的一个重要区别,可变对象往往是修改自己的属性。
|
||||||
|
|
||||||
|
所有的修改操作都创建一个新的不可变对象,你可能会有这种担心:是不是创建的对象太多了,有点太浪费内存呢?是的,这样做的确有些浪费,那如何解决呢?
|
||||||
|
|
||||||
|
利用享元模式避免创建重复对象
|
||||||
|
|
||||||
|
如果你熟悉面向对象相关的设计模式,相信你一定能想到享元模式(Flyweight Pattern)。利用享元模式可以减少创建对象的数量,从而减少内存占用。Java语言里面Long、Integer、Short、Byte等这些基本数据类型的包装类都用到了享元模式。
|
||||||
|
|
||||||
|
下面我们就以Long这个类作为例子,看看它是如何利用享元模式来优化对象的创建的。
|
||||||
|
|
||||||
|
享元模式本质上其实就是一个对象池,利用享元模式创建对象的逻辑也很简单:创建之前,首先去对象池里看看是不是存在;如果已经存在,就利用对象池里的对象;如果不存在,就会新创建一个对象,并且把这个新创建出来的对象放进对象池里。
|
||||||
|
|
||||||
|
Long这个类并没有照搬享元模式,Long内部维护了一个静态的对象池,仅缓存了[-128,127]之间的数字,这个对象池在JVM启动的时候就创建好了,而且这个对象池一直都不会变化,也就是说它是静态的。之所以采用这样的设计,是因为Long这个对象的状态共有 264 种,实在太多,不宜全部缓存,而[-128,127]之间的数字利用率最高。下面的示例代码出自Java 1.8,valueOf()方法就用到了LongCache这个缓存,你可以结合着来加深理解。
|
||||||
|
|
||||||
|
Long valueOf(long l) {
|
||||||
|
final int offset = 128;
|
||||||
|
// [-128,127]直接的数字做了缓存
|
||||||
|
if (l >= -128 && l <= 127) {
|
||||||
|
return LongCache
|
||||||
|
.cache[(int)l + offset];
|
||||||
|
}
|
||||||
|
return new Long(l);
|
||||||
|
}
|
||||||
|
//缓存,等价于对象池
|
||||||
|
//仅缓存[-128,127]直接的数字
|
||||||
|
static class LongCache {
|
||||||
|
static final Long cache[]
|
||||||
|
= new Long[-(-128) + 127 + 1];
|
||||||
|
|
||||||
|
static {
|
||||||
|
for(int i=0; i<cache.length; i++)
|
||||||
|
cache[i] = new Long(i-128);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
前面我们在《13 | 理论基础模块热点问题答疑》中提到“Integer 和 String 类型的对象不适合做锁”,其实基本上所有的基础类型的包装类都不适合做锁,因为它们内部用到了享元模式,这会导致看上去私有的锁,其实是共有的。例如在下面代码中,本意是A用锁al,B用锁bl,各自管理各自的,互不影响。但实际上al和bl是一个对象,结果A和B共用的是一把锁。
|
||||||
|
|
||||||
|
class A {
|
||||||
|
Long al=Long.valueOf(1);
|
||||||
|
public void setAX(){
|
||||||
|
synchronized (al) {
|
||||||
|
//省略代码无数
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class B {
|
||||||
|
Long bl=Long.valueOf(1);
|
||||||
|
public void setBY(){
|
||||||
|
synchronized (bl) {
|
||||||
|
//省略代码无数
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
使用Immutability模式的注意事项
|
||||||
|
|
||||||
|
在使用Immutability模式的时候,需要注意以下两点:
|
||||||
|
|
||||||
|
|
||||||
|
对象的所有属性都是final的,并不能保证不可变性;
|
||||||
|
不可变对象也需要正确发布。
|
||||||
|
|
||||||
|
|
||||||
|
在Java语言中,final修饰的属性一旦被赋值,就不可以再修改,但是如果属性的类型是普通对象,那么这个普通对象的属性是可以被修改的。例如下面的代码中,Bar的属性foo虽然是final的,依然可以通过setAge()方法来设置foo的属性age。所以,在使用Immutability模式的时候一定要确认保持不变性的边界在哪里,是否要求属性对象也具备不可变性。
|
||||||
|
|
||||||
|
class Foo{
|
||||||
|
int age=0;
|
||||||
|
int name="abc";
|
||||||
|
}
|
||||||
|
final class Bar {
|
||||||
|
final Foo foo;
|
||||||
|
void setAge(int a){
|
||||||
|
foo.age=a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
下面我们再看看如何正确地发布不可变对象。不可变对象虽然是线程安全的,但是并不意味着引用这些不可变对象的对象就是线程安全的。例如在下面的代码中,Foo具备不可变性,线程安全,但是类Bar并不是线程安全的,类Bar中持有对Foo的引用foo,对foo这个引用的修改在多线程中并不能保证可见性和原子性。
|
||||||
|
|
||||||
|
//Foo线程安全
|
||||||
|
final class Foo{
|
||||||
|
final int age=0;
|
||||||
|
final int name="abc";
|
||||||
|
}
|
||||||
|
//Bar线程不安全
|
||||||
|
class Bar {
|
||||||
|
Foo foo;
|
||||||
|
void setFoo(Foo f){
|
||||||
|
this.foo=f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
如果你的程序仅仅需要foo保持可见性,无需保证原子性,那么可以将foo声明为volatile变量,这样就能保证可见性。如果你的程序需要保证原子性,那么可以通过原子类来实现。下面的示例代码是合理库存的原子化实现,你应该很熟悉了,其中就是用原子类解决了不可变对象引用的原子性问题。
|
||||||
|
|
||||||
|
public class SafeWM {
|
||||||
|
class WMRange{
|
||||||
|
final int upper;
|
||||||
|
final int lower;
|
||||||
|
WMRange(int upper,int lower){
|
||||||
|
//省略构造函数实现
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final AtomicReference<WMRange>
|
||||||
|
rf = new AtomicReference<>(
|
||||||
|
new WMRange(0,0)
|
||||||
|
);
|
||||||
|
// 设置库存上限
|
||||||
|
void setUpper(int v){
|
||||||
|
while(true){
|
||||||
|
WMRange or = rf.get();
|
||||||
|
// 检查参数合法性
|
||||||
|
if(v < or.lower){
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
WMRange nr = new
|
||||||
|
WMRange(v, or.lower);
|
||||||
|
if(rf.compareAndSet(or, nr)){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
利用Immutability模式解决并发问题,也许你觉得有点陌生,其实你天天都在享受它的战果。Java语言里面的String和Long、Integer、Double等基础类型的包装类都具备不可变性,这些对象的线程安全性都是靠不可变性来保证的。Immutability模式是最简单的解决并发问题的方法,建议当你试图解决一个并发问题时,可以首先尝试一下Immutability模式,看是否能够快速解决。
|
||||||
|
|
||||||
|
具备不变性的对象,只有一种状态,这个状态由对象内部所有的不变属性共同决定。其实还有一种更简单的不变性对象,那就是无状态。无状态对象内部没有属性,只有方法。除了无状态的对象,你可能还听说过无状态的服务、无状态的协议等等。无状态有很多好处,最核心的一点就是性能。在多线程领域,无状态对象没有线程安全问题,无需同步处理,自然性能很好;在分布式领域,无状态意味着可以无限地水平扩展,所以分布式领域里面性能的瓶颈一定不是出在无状态的服务节点上。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
下面的示例代码中,Account的属性是final的,并且只有get方法,那这个类是不是具备不可变性呢?
|
||||||
|
|
||||||
|
public final class Account{
|
||||||
|
private final
|
||||||
|
StringBuffer user;
|
||||||
|
public Account(String user){
|
||||||
|
this.user =
|
||||||
|
new StringBuffer(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StringBuffer getUser(){
|
||||||
|
return this.user;
|
||||||
|
}
|
||||||
|
public String toString(){
|
||||||
|
return "user"+user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
116
专栏/Java并发编程实战/29Copy-on-Write模式:不是延时策略的COW.md
Normal file
116
专栏/Java并发编程实战/29Copy-on-Write模式:不是延时策略的COW.md
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
29 Copy-on-Write模式:不是延时策略的COW
|
||||||
|
在上一篇文章中我们讲到Java里String这个类在实现replace()方法的时候,并没有更改原字符串里面value[]数组的内容,而是创建了一个新字符串,这种方法在解决不可变对象的修改问题时经常用到。如果你深入地思考这个方法,你会发现它本质上是一种Copy-on-Write方法。所谓Copy-on-Write,经常被缩写为COW或者CoW,顾名思义就是写时复制。
|
||||||
|
|
||||||
|
不可变对象的写操作往往都是使用Copy-on-Write方法解决的,当然Copy-on-Write的应用领域并不局限于Immutability模式。下面我们先简单介绍一下Copy-on-Write的应用领域,让你对它有个更全面的认识。
|
||||||
|
|
||||||
|
Copy-on-Write模式的应用领域
|
||||||
|
|
||||||
|
我们前面在《20 | 并发容器:都有哪些“坑”需要我们填?》中介绍过CopyOnWriteArrayList和CopyOnWriteArraySet这两个Copy-on-Write容器,它们背后的设计思想就是Copy-on-Write;通过Copy-on-Write这两个容器实现的读操作是无锁的,由于无锁,所以将读操作的性能发挥到了极致。
|
||||||
|
|
||||||
|
除了Java这个领域,Copy-on-Write在操作系统领域也有广泛的应用。
|
||||||
|
|
||||||
|
我第一次接触Copy-on-Write其实就是在操作系统领域。类Unix的操作系统中创建进程的API是fork(),传统的fork()函数会创建父进程的一个完整副本,例如父进程的地址空间现在用到了1G的内存,那么fork()子进程的时候要复制父进程整个进程的地址空间(占有1G内存)给子进程,这个过程是很耗时的。而Linux中的fork()函数就聪明得多了,fork()子进程的时候,并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间;只用在父进程或者子进程需要写入的时候才会复制地址空间,从而使父子进程拥有各自的地址空间。
|
||||||
|
|
||||||
|
本质上来讲,父子进程的地址空间以及数据都是要隔离的,使用Copy-on-Write更多地体现的是一种延时策略,只有在真正需要复制的时候才复制,而不是提前复制好,同时Copy-on-Write还支持按需复制,所以Copy-on-Write在操作系统领域是能够提升性能的。相比较而言,Java提供的Copy-on-Write容器,由于在修改的同时会复制整个容器,所以在提升读操作性能的同时,是以内存复制为代价的。这里你会发现,同样是应用Copy-on-Write,不同的场景,对性能的影响是不同的。
|
||||||
|
|
||||||
|
在操作系统领域,除了创建进程用到了Copy-on-Write,很多文件系统也同样用到了,例如Btrfs (B-Tree File System)、aufs(advanced multi-layered unification filesystem)等。
|
||||||
|
|
||||||
|
除了上面我们说的Java领域、操作系统领域,很多其他领域也都能看到Copy-on-Write的身影:Docker容器镜像的设计是Copy-on-Write,甚至分布式源码管理系统Git背后的设计思想都有Copy-on-Write……
|
||||||
|
|
||||||
|
不过,Copy-on-Write最大的应用领域还是在函数式编程领域。函数式编程的基础是不可变性(Immutability),所以函数式编程里面所有的修改操作都需要Copy-on-Write来解决。你或许会有疑问,“所有数据的修改都需要复制一份,性能是不是会成为瓶颈呢?”你的担忧是有道理的,之所以函数式编程早年间没有兴起,性能绝对拖了后腿。但是随着硬件性能的提升,性能问题已经慢慢变得可以接受了。而且,Copy-on-Write也远不像Java里的CopyOnWriteArrayList那样笨:整个数组都复制一遍。Copy-on-Write也是可以按需复制的,如果你感兴趣可以参考Purely Functional Data Structures这本书,里面描述了各种具备不变性的数据结构的实现。
|
||||||
|
|
||||||
|
CopyOnWriteArrayList和CopyOnWriteArraySet这两个Copy-on-Write容器在修改的时候会复制整个数组,所以如果容器经常被修改或者这个数组本身就非常大的时候,是不建议使用的。反之,如果是修改非常少、数组数量也不大,并且对读性能要求苛刻的场景,使用Copy-on-Write容器效果就非常好了。下面我们结合一个真实的案例来讲解一下。
|
||||||
|
|
||||||
|
一个真实案例
|
||||||
|
|
||||||
|
我曾经写过一个RPC框架,有点类似Dubbo,服务提供方是多实例分布式部署的,所以服务的客户端在调用RPC的时候,会选定一个服务实例来调用,这个选定的过程本质上就是在做负载均衡,而做负载均衡的前提是客户端要有全部的路由信息。例如在下图中,A服务的提供方有3个实例,分别是192.168.1.1、192.168.1.2和192.168.1.3,客户端在调用目标服务A前,首先需要做的是负载均衡,也就是从这3个实例中选出1个来,然后再通过RPC把请求发送选中的目标实例。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
RPC路由关系图
|
||||||
|
|
||||||
|
RPC框架的一个核心任务就是维护服务的路由关系,我们可以把服务的路由关系简化成下图所示的路由表。当服务提供方上线或者下线的时候,就需要更新客户端的这张路由表。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
我们首先来分析一下如何用程序来实现。每次RPC调用都需要通过负载均衡器来计算目标服务的IP和端口号,而负载均衡器需要通过路由表获取接口的所有路由信息,也就是说,每次RPC调用都需要访问路由表,所以访问路由表这个操作的性能要求是很高的。不过路由表对数据的一致性要求并不高,一个服务提供方从上线到反馈到客户端的路由表里,即便有5秒钟,很多时候也都是能接受的(5秒钟,对于以纳秒作为时钟周期的CPU来说,那何止是一万年,所以路由表对一致性的要求并不高)。而且路由表是典型的读多写少类问题,写操作的量相比于读操作,可谓是沧海一粟,少得可怜。
|
||||||
|
|
||||||
|
通过以上分析,你会发现一些关键词:对读的性能要求很高,读多写少,弱一致性。它们综合在一起,你会想到什么呢?CopyOnWriteArrayList和CopyOnWriteArraySet天生就适用这种场景啊。所以下面的示例代码中,RouteTable这个类内部我们通过ConcurrentHashMap<String, CopyOnWriteArraySet<Router>>这个数据结构来描述路由表,ConcurrentHashMap的Key是接口名,Value是路由集合,这个路由集合我们用是CopyOnWriteArraySet。
|
||||||
|
|
||||||
|
下面我们再来思考Router该如何设计,服务提供方的每一次上线、下线都会更新路由信息,这时候你有两种选择。一种是通过更新Router的一个状态位来标识,如果这样做,那么所有访问该状态位的地方都需要同步访问,这样很影响性能。另外一种就是采用Immutability模式,每次上线、下线都创建新的Router对象或者删除对应的Router对象。由于上线、下线的频率很低,所以后者是最好的选择。
|
||||||
|
|
||||||
|
Router的实现代码如下所示,是一种典型Immutability模式的实现,需要你注意的是我们重写了equals方法,这样CopyOnWriteArraySet的add()和remove()方法才能正常工作。
|
||||||
|
|
||||||
|
//路由信息
|
||||||
|
public final class Router{
|
||||||
|
private final String ip;
|
||||||
|
private final Integer port;
|
||||||
|
private final String iface;
|
||||||
|
//构造函数
|
||||||
|
public Router(String ip,
|
||||||
|
Integer port, String iface){
|
||||||
|
this.ip = ip;
|
||||||
|
this.port = port;
|
||||||
|
this.iface = iface;
|
||||||
|
}
|
||||||
|
//重写equals方法
|
||||||
|
public boolean equals(Object obj){
|
||||||
|
if (obj instanceof Router) {
|
||||||
|
Router r = (Router)obj;
|
||||||
|
return iface.equals(r.iface) &&
|
||||||
|
ip.equals(r.ip) &&
|
||||||
|
port.equals(r.port);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
public int hashCode() {
|
||||||
|
//省略hashCode相关代码
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//路由表信息
|
||||||
|
public class RouterTable {
|
||||||
|
//Key:接口名
|
||||||
|
//Value:路由集合
|
||||||
|
ConcurrentHashMap<String, CopyOnWriteArraySet<Router>>
|
||||||
|
rt = new ConcurrentHashMap<>();
|
||||||
|
//根据接口名获取路由表
|
||||||
|
public Set<Router> get(String iface){
|
||||||
|
return rt.get(iface);
|
||||||
|
}
|
||||||
|
//删除路由
|
||||||
|
public void remove(Router router) {
|
||||||
|
Set<Router> set=rt.get(router.iface);
|
||||||
|
if (set != null) {
|
||||||
|
set.remove(router);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//增加路由
|
||||||
|
public void add(Router router) {
|
||||||
|
Set<Router> set = rt.computeIfAbsent(
|
||||||
|
route.iface, r ->
|
||||||
|
new CopyOnWriteArraySet<>());
|
||||||
|
set.add(router);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
目前Copy-on-Write在Java并发编程领域知名度不是很高,很多人都在无意中把它忽视了,但其实Copy-on-Write才是最简单的并发解决方案。它是如此简单,以至于Java中的基本数据类型String、Integer、Long等都是基于Copy-on-Write方案实现的。
|
||||||
|
|
||||||
|
Copy-on-Write是一项非常通用的技术方案,在很多领域都有着广泛的应用。不过,它也有缺点的,那就是消耗内存,每次修改都需要复制一个新的对象出来,好在随着自动垃圾回收(GC)算法的成熟以及硬件的发展,这种内存消耗已经渐渐可以接受了。所以在实际工作中,如果写操作非常少,那你就可以尝试用一下Copy-on-Write,效果还是不错的。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
Java提供了CopyOnWriteArrayList,为什么没有提供CopyOnWriteLinkedList呢?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
168
专栏/Java并发编程实战/30线程本地存储模式:没有共享,就没有伤害.md
Normal file
168
专栏/Java并发编程实战/30线程本地存储模式:没有共享,就没有伤害.md
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
30 线程本地存储模式:没有共享,就没有伤害
|
||||||
|
民国年间某山东省主席参加某大学校庆演讲,在篮球场看到十来个人穿着裤衩抢一个球,观之实在不雅,于是怒斥学校的总务处长贪污,并且发话:“多买几个球,一人发一个,省得你争我抢!”小时候听到这个段子只是觉得好玩,今天再来看,却别有一番滋味。为什么呢?因为其间蕴藏着解决并发问题的一个重要方法:避免共享。
|
||||||
|
|
||||||
|
我们曾经一遍一遍又一遍地重复,多个线程同时读写同一共享变量存在并发问题。前面两篇文章我们突破的是写,没有写操作自然没有并发问题了。其实还可以突破共享变量,没有共享变量也不会有并发问题,正所谓是没有共享,就没有伤害。
|
||||||
|
|
||||||
|
那如何避免共享呢?思路其实很简单,多个人争一个球总容易出矛盾,那就每个人发一个球。对应到并发编程领域,就是每个线程都拥有自己的变量,彼此之间不共享,也就没有并发问题了。
|
||||||
|
|
||||||
|
我们在《11 | Java线程(下):为什么局部变量是线程安全的?》中提到过线程封闭,其本质上就是避免共享。你已经知道通过局部变量可以做到避免共享,那还有没有其他方法可以做到呢?有的,Java语言提供的线程本地存储(ThreadLocal)就能够做到。下面我们先看看ThreadLocal到底该如何使用。
|
||||||
|
|
||||||
|
ThreadLocal的使用方法
|
||||||
|
|
||||||
|
下面这个静态类ThreadId会为每个线程分配一个唯一的线程Id,如果一个线程前后两次调用ThreadId的get()方法,两次get()方法的返回值是相同的。但如果是两个线程分别调用ThreadId的get()方法,那么两个线程看到的get()方法的返回值是不同的。若你是初次接触ThreadLocal,可能会觉得奇怪,为什么相同线程调用get()方法结果就相同,而不同线程调用get()方法结果就不同呢?
|
||||||
|
|
||||||
|
static class ThreadId {
|
||||||
|
static final AtomicLong
|
||||||
|
nextId=new AtomicLong(0);
|
||||||
|
//定义ThreadLocal变量
|
||||||
|
static final ThreadLocal<Long>
|
||||||
|
tl=ThreadLocal.withInitial(
|
||||||
|
()->nextId.getAndIncrement());
|
||||||
|
//此方法会为每个线程分配一个唯一的Id
|
||||||
|
static long get(){
|
||||||
|
return tl.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
能有这个奇怪的结果,都是ThreadLocal的杰作,不过在详细解释ThreadLocal的工作原理之前,我们再看一个实际工作中可能遇到的例子来加深一下对ThreadLocal的理解。你可能知道SimpleDateFormat不是线程安全的,那如果需要在并发场景下使用它,你该怎么办呢?
|
||||||
|
|
||||||
|
其实有一个办法就是用ThreadLocal来解决,下面的示例代码就是ThreadLocal解决方案的具体实现,这段代码与前面ThreadId的代码高度相似,同样地,不同线程调用SafeDateFormat的get()方法将返回不同的SimpleDateFormat对象实例,由于不同线程并不共享SimpleDateFormat,所以就像局部变量一样,是线程安全的。
|
||||||
|
|
||||||
|
static class SafeDateFormat {
|
||||||
|
//定义ThreadLocal变量
|
||||||
|
static final ThreadLocal<DateFormat>
|
||||||
|
tl=ThreadLocal.withInitial(
|
||||||
|
()-> new SimpleDateFormat(
|
||||||
|
"yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
static DateFormat get(){
|
||||||
|
return tl.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//不同线程执行下面代码
|
||||||
|
//返回的df是不同的
|
||||||
|
DateFormat df =
|
||||||
|
SafeDateFormat.get();
|
||||||
|
|
||||||
|
|
||||||
|
通过上面两个例子,相信你对ThreadLocal的用法以及应用场景都了解了,下面我们就来详细解释ThreadLocal的工作原理。
|
||||||
|
|
||||||
|
ThreadLocal的工作原理
|
||||||
|
|
||||||
|
在解释ThreadLocal的工作原理之前, 你先自己想想:如果让你来实现ThreadLocal的功能,你会怎么设计呢?ThreadLocal的目标是让不同的线程有不同的变量V,那最直接的方法就是创建一个Map,它的Key是线程,Value是每个线程拥有的变量V,ThreadLocal内部持有这样的一个Map就可以了。你可以参考下面的示意图和示例代码来理解。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ThreadLocal持有Map的示意图
|
||||||
|
|
||||||
|
class MyThreadLocal<T> {
|
||||||
|
Map<Thread, T> locals =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
//获取线程变量
|
||||||
|
T get() {
|
||||||
|
return locals.get(
|
||||||
|
Thread.currentThread());
|
||||||
|
}
|
||||||
|
//设置线程变量
|
||||||
|
void set(T t) {
|
||||||
|
locals.put(
|
||||||
|
Thread.currentThread(), t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
那Java的ThreadLocal是这么实现的吗?这一次我们的设计思路和Java的实现差异很大。Java的实现里面也有一个Map,叫做ThreadLocalMap,不过持有ThreadLocalMap的不是ThreadLocal,而是Thread。Thread这个类内部有一个私有属性threadLocals,其类型就是ThreadLocalMap,ThreadLocalMap的Key是ThreadLocal。你可以结合下面的示意图和精简之后的Java实现代码来理解。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Thread持有ThreadLocalMap的示意图
|
||||||
|
|
||||||
|
class Thread {
|
||||||
|
//内部持有ThreadLocalMap
|
||||||
|
ThreadLocal.ThreadLocalMap
|
||||||
|
threadLocals;
|
||||||
|
}
|
||||||
|
class ThreadLocal<T>{
|
||||||
|
public T get() {
|
||||||
|
//首先获取线程持有的
|
||||||
|
//ThreadLocalMap
|
||||||
|
ThreadLocalMap map =
|
||||||
|
Thread.currentThread()
|
||||||
|
.threadLocals;
|
||||||
|
//在ThreadLocalMap中
|
||||||
|
//查找变量
|
||||||
|
Entry e =
|
||||||
|
map.getEntry(this);
|
||||||
|
return e.value;
|
||||||
|
}
|
||||||
|
static class ThreadLocalMap{
|
||||||
|
//内部是数组而不是Map
|
||||||
|
Entry[] table;
|
||||||
|
//根据ThreadLocal查找Entry
|
||||||
|
Entry getEntry(ThreadLocal key){
|
||||||
|
//省略查找逻辑
|
||||||
|
}
|
||||||
|
//Entry定义
|
||||||
|
static class Entry extends
|
||||||
|
WeakReference<ThreadLocal>{
|
||||||
|
Object value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
初看上去,我们的设计方案和Java的实现仅仅是Map的持有方不同而已,我们的设计里面Map属于ThreadLocal,而Java的实现里面ThreadLocalMap则是属于Thread。这两种方式哪种更合理呢?很显然Java的实现更合理一些。在Java的实现方案里面,ThreadLocal仅仅是一个代理工具类,内部并不持有任何与线程相关的数据,所有和线程相关的数据都存储在Thread里面,这样的设计容易理解。而从数据的亲缘性上来讲,ThreadLocalMap属于Thread也更加合理。
|
||||||
|
|
||||||
|
当然还有一个更加深层次的原因,那就是不容易产生内存泄露。在我们的设计方案中,ThreadLocal持有的Map会持有Thread对象的引用,这就意味着,只要ThreadLocal对象存在,那么Map中的Thread对象就永远不会被回收。ThreadLocal的生命周期往往都比线程要长,所以这种设计方案很容易导致内存泄露。而Java的实现中Thread持有ThreadLocalMap,而且ThreadLocalMap里对ThreadLocal的引用还是弱引用(WeakReference),所以只要Thread对象可以被回收,那么ThreadLocalMap就能被回收。Java的这种实现方案虽然看上去复杂一些,但是更加安全。
|
||||||
|
|
||||||
|
Java的ThreadLocal实现应该称得上深思熟虑了,不过即便如此深思熟虑,还是不能百分百地让程序员避免内存泄露,例如在线程池中使用ThreadLocal,如果不谨慎就可能导致内存泄露。
|
||||||
|
|
||||||
|
ThreadLocal与内存泄露
|
||||||
|
|
||||||
|
在线程池中使用ThreadLocal为什么可能导致内存泄露呢?原因就出在线程池中线程的存活时间太长,往往都是和程序同生共死的,这就意味着Thread持有的ThreadLocalMap一直都不会被回收,再加上ThreadLocalMap中的Entry对ThreadLocal是弱引用(WeakReference),所以只要ThreadLocal结束了自己的生命周期是可以被回收掉的。但是Entry中的Value却是被Entry强引用的,所以即便Value的生命周期结束了,Value也是无法被回收的,从而导致内存泄露。
|
||||||
|
|
||||||
|
那在线程池中,我们该如何正确使用ThreadLocal呢?其实很简单,既然JVM不能做到自动释放对Value的强引用,那我们手动释放就可以了。如何能做到手动释放呢?估计你马上想到try{}finally{}方案了,这个简直就是手动释放资源的利器。示例的代码如下,你可以参考学习。
|
||||||
|
|
||||||
|
ExecutorService es;
|
||||||
|
ThreadLocal tl;
|
||||||
|
es.execute(()->{
|
||||||
|
//ThreadLocal增加变量
|
||||||
|
tl.set(obj);
|
||||||
|
try {
|
||||||
|
// 省略业务逻辑代码
|
||||||
|
}finally {
|
||||||
|
//手动清理ThreadLocal
|
||||||
|
tl.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
InheritableThreadLocal与继承性
|
||||||
|
|
||||||
|
通过ThreadLocal创建的线程变量,其子线程是无法继承的。也就是说你在线程中通过ThreadLocal创建了线程变量V,而后该线程创建了子线程,你在子线程中是无法通过ThreadLocal来访问父线程的线程变量V的。
|
||||||
|
|
||||||
|
如果你需要子线程继承父线程的线程变量,那该怎么办呢?其实很简单,Java提供了InheritableThreadLocal来支持这种特性,InheritableThreadLocal是ThreadLocal子类,所以用法和ThreadLocal相同,这里就不多介绍了。
|
||||||
|
|
||||||
|
不过,我完全不建议你在线程池中使用InheritableThreadLocal,不仅仅是因为它具有ThreadLocal相同的缺点——可能导致内存泄露,更重要的原因是:线程池中线程的创建是动态的,很容易导致继承关系错乱,如果你的业务逻辑依赖InheritableThreadLocal,那么很可能导致业务逻辑计算错误,而这个错误往往比内存泄露更要命。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
线程本地存储模式本质上是一种避免共享的方案,由于没有共享,所以自然也就没有并发问题。如果你需要在并发场景中使用一个线程不安全的工具类,最简单的方案就是避免共享。避免共享有两种方案,一种方案是将这个工具类作为局部变量使用,另外一种方案就是线程本地存储模式。这两种方案,局部变量方案的缺点是在高并发场景下会频繁创建对象,而线程本地存储方案,每个线程只需要创建一个工具类的实例,所以不存在频繁创建对象的问题。
|
||||||
|
|
||||||
|
线程本地存储模式是解决并发问题的常用方案,所以Java SDK也提供了相应的实现:ThreadLocal。通过上面我们的分析,你应该能体会到Java SDK的实现已经是深思熟虑了,不过即便如此,仍不能尽善尽美,例如在线程池中使用ThreadLocal仍可能导致内存泄漏,所以使用ThreadLocal还是需要你打起精神,足够谨慎。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
实际工作中,有很多平台型的技术方案都是采用ThreadLocal来传递一些上下文信息,例如Spring使用ThreadLocal来传递事务信息。我们曾经说过,异步编程已经很成熟了,那你觉得在异步场景中,是否可以使用Spring的事务管理器呢?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
241
专栏/Java并发编程实战/31GuardedSuspension模式:等待唤醒机制的规范实现.md
Normal file
241
专栏/Java并发编程实战/31GuardedSuspension模式:等待唤醒机制的规范实现.md
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
31 Guarded Suspension模式:等待唤醒机制的规范实现
|
||||||
|
前不久,同事小灰工作中遇到一个问题,他开发了一个Web项目:Web版的文件浏览器,通过它用户可以在浏览器里查看服务器上的目录和文件。这个项目依赖运维部门提供的文件浏览服务,而这个文件浏览服务只支持消息队列(MQ)方式接入。消息队列在互联网大厂中用的非常多,主要用作流量削峰和系统解耦。在这种接入方式中,发送消息和消费结果这两个操作之间是异步的,你可以参考下面的示意图来理解。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
消息队列(MQ)示意图
|
||||||
|
|
||||||
|
在小灰的这个Web项目中,用户通过浏览器发过来一个请求,会被转换成一个异步消息发送给MQ,等MQ返回结果后,再将这个结果返回至浏览器。小灰同学的问题是:给MQ发送消息的线程是处理Web请求的线程T1,但消费MQ结果的线程并不是线程T1,那线程T1如何等待MQ的返回结果呢?为了便于你理解这个场景,我将其代码化了,示例代码如下。
|
||||||
|
|
||||||
|
class Message{
|
||||||
|
String id;
|
||||||
|
String content;
|
||||||
|
}
|
||||||
|
//该方法可以发送消息
|
||||||
|
void send(Message msg){
|
||||||
|
//省略相关代码
|
||||||
|
}
|
||||||
|
//MQ消息返回后会调用该方法
|
||||||
|
//该方法的执行线程不同于
|
||||||
|
//发送消息的线程
|
||||||
|
void onMessage(Message msg){
|
||||||
|
//省略相关代码
|
||||||
|
}
|
||||||
|
//处理浏览器发来的请求
|
||||||
|
Respond handleWebReq(){
|
||||||
|
//创建一消息
|
||||||
|
Message msg1 = new
|
||||||
|
Message("1","{...}");
|
||||||
|
//发送消息
|
||||||
|
send(msg1);
|
||||||
|
//如何等待MQ返回的消息呢?
|
||||||
|
String result = ...;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
看到这里,相信你一定有点似曾相识的感觉,这不就是前面我们在《15 | Lock和Condition(下):Dubbo如何用管程实现异步转同步?》中曾介绍过的异步转同步问题吗?仔细分析,的确是这样,不过在那一篇文章中我们只是介绍了最终方案,让你知其然,但是并没有介绍这个方案是如何设计出来的,今天咱们再仔细聊聊这个问题,让你知其所以然,遇到类似问题也能自己设计出方案来。
|
||||||
|
|
||||||
|
Guarded Suspension模式
|
||||||
|
|
||||||
|
上面小灰遇到的问题,在现实世界里比比皆是,只是我们一不小心就忽略了。比如,项目组团建要外出聚餐,我们提前预订了一个包间,然后兴冲冲地奔过去,到那儿后大堂经理看了一眼包间,发现服务员正在收拾,就会告诉我们:“您预订的包间服务员正在收拾,请您稍等片刻。”过了一会,大堂经理发现包间已经收拾完了,于是马上带我们去包间就餐。
|
||||||
|
|
||||||
|
我们等待包间收拾完的这个过程和小灰遇到的等待MQ返回消息本质上是一样的,都是等待一个条件满足:就餐需要等待包间收拾完,小灰的程序里要等待MQ返回消息。
|
||||||
|
|
||||||
|
那我们来看看现实世界里是如何解决这类问题的呢?现实世界里大堂经理这个角色很重要,我们是否等待,完全是由他来协调的。通过类比,相信你也一定有思路了:我们的程序里,也需要这样一个大堂经理。的确是这样,那程序世界里的大堂经理该如何设计呢?其实设计方案前人早就搞定了,而且还将其总结成了一个设计模式:Guarded Suspension。所谓Guarded Suspension,直译过来就是“保护性地暂停”。那下面我们就来看看,Guarded Suspension模式是如何模拟大堂经理进行保护性地暂停的。
|
||||||
|
|
||||||
|
下图就是Guarded Suspension模式的结构图,非常简单,一个对象GuardedObject,内部有一个成员变量——受保护的对象,以及两个成员方法——get(Predicate<T> p)和onChanged(T obj)方法。其中,对象GuardedObject就是我们前面提到的大堂经理,受保护对象就是餐厅里面的包间;受保护对象的get()方法对应的是我们的就餐,就餐的前提条件是包间已经收拾好了,参数p就是用来描述这个前提条件的;受保护对象的onChanged()方法对应的是服务员把包间收拾好了,通过onChanged()方法可以fire一个事件,而这个事件往往能改变前提条件p的计算结果。下图中,左侧的绿色线程就是需要就餐的顾客,而右侧的蓝色线程就是收拾包间的服务员。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Guarded Suspension模式结构图
|
||||||
|
|
||||||
|
GuardedObject的内部实现非常简单,是管程的一个经典用法,你可以参考下面的示例代码,核心是:get()方法通过条件变量的await()方法实现等待,onChanged()方法通过条件变量的signalAll()方法实现唤醒功能。逻辑还是很简单的,所以这里就不再详细介绍了。
|
||||||
|
|
||||||
|
class GuardedObject<T>{
|
||||||
|
//受保护的对象
|
||||||
|
T obj;
|
||||||
|
final Lock lock =
|
||||||
|
new ReentrantLock();
|
||||||
|
final Condition done =
|
||||||
|
lock.newCondition();
|
||||||
|
final int timeout=1;
|
||||||
|
//获取受保护对象
|
||||||
|
T get(Predicate<T> p) {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
//MESA管程推荐写法
|
||||||
|
while(!p.test(obj)){
|
||||||
|
done.await(timeout,
|
||||||
|
TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
}catch(InterruptedException e){
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}finally{
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
//返回非空的受保护对象
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
//事件通知方法
|
||||||
|
void onChanged(T obj) {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
this.obj = obj;
|
||||||
|
done.signalAll();
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
扩展Guarded Suspension模式
|
||||||
|
|
||||||
|
上面我们介绍了Guarded Suspension模式及其实现,这个模式能够模拟现实世界里大堂经理的角色,那现在我们再来看看这个“大堂经理”能否解决小灰同学遇到的问题。
|
||||||
|
|
||||||
|
Guarded Suspension模式里GuardedObject有两个核心方法,一个是get()方法,一个是onChanged()方法。很显然,在处理Web请求的方法handleWebReq()中,可以调用GuardedObject的get()方法来实现等待;在MQ消息的消费方法onMessage()中,可以调用GuardedObject的onChanged()方法来实现唤醒。
|
||||||
|
|
||||||
|
//处理浏览器发来的请求
|
||||||
|
Respond handleWebReq(){
|
||||||
|
//创建一消息
|
||||||
|
Message msg1 = new
|
||||||
|
Message("1","{...}");
|
||||||
|
//发送消息
|
||||||
|
send(msg1);
|
||||||
|
//利用GuardedObject实现等待
|
||||||
|
GuardedObject<Message> go
|
||||||
|
=new GuardObjec<>();
|
||||||
|
Message r = go.get(
|
||||||
|
t->t != null);
|
||||||
|
}
|
||||||
|
void onMessage(Message msg){
|
||||||
|
//如何找到匹配的go?
|
||||||
|
GuardedObject<Message> go=???
|
||||||
|
go.onChanged(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
但是在实现的时候会遇到一个问题,handleWebReq()里面创建了GuardedObject对象的实例go,并调用其get()方等待结果,那在onMessage()方法中,如何才能够找到匹配的GuardedObject对象呢?这个过程类似服务员告诉大堂经理某某包间已经收拾好了,大堂经理如何根据包间找到就餐的人。现实世界里,大堂经理的头脑中,有包间和就餐人之间的关系图,所以服务员说完之后大堂经理立刻就能把就餐人找出来。
|
||||||
|
|
||||||
|
我们可以参考大堂经理识别就餐人的办法,来扩展一下Guarded Suspension模式,从而使它能够很方便地解决小灰同学的问题。在小灰的程序中,每个发送到MQ的消息,都有一个唯一性的属性id,所以我们可以维护一个MQ消息id和GuardedObject对象实例的关系,这个关系可以类比大堂经理大脑里维护的包间和就餐人的关系。
|
||||||
|
|
||||||
|
有了这个关系,我们来看看具体如何实现。下面的示例代码是扩展Guarded Suspension模式的实现,扩展后的GuardedObject内部维护了一个Map,其Key是MQ消息id,而Value是GuardedObject对象实例,同时增加了静态方法create()和fireEvent();create()方法用来创建一个GuardedObject对象实例,并根据key值将其加入到Map中,而fireEvent()方法则是模拟的大堂经理根据包间找就餐人的逻辑。
|
||||||
|
|
||||||
|
class GuardedObject<T>{
|
||||||
|
//受保护的对象
|
||||||
|
T obj;
|
||||||
|
final Lock lock =
|
||||||
|
new ReentrantLock();
|
||||||
|
final Condition done =
|
||||||
|
lock.newCondition();
|
||||||
|
final int timeout=2;
|
||||||
|
//保存所有GuardedObject
|
||||||
|
final static Map<Object, GuardedObject>
|
||||||
|
gos=new ConcurrentHashMap<>();
|
||||||
|
//静态方法创建GuardedObject
|
||||||
|
static <K> GuardedObject
|
||||||
|
create(K key){
|
||||||
|
GuardedObject go=new GuardedObject();
|
||||||
|
gos.put(key, go);
|
||||||
|
return go;
|
||||||
|
}
|
||||||
|
static <K, T> void
|
||||||
|
fireEvent(K key, T obj){
|
||||||
|
GuardedObject go=gos.remove(key);
|
||||||
|
if (go != null){
|
||||||
|
go.onChanged(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//获取受保护对象
|
||||||
|
T get(Predicate<T> p) {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
//MESA管程推荐写法
|
||||||
|
while(!p.test(obj)){
|
||||||
|
done.await(timeout,
|
||||||
|
TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
}catch(InterruptedException e){
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}finally{
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
//返回非空的受保护对象
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
//事件通知方法
|
||||||
|
void onChanged(T obj) {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
this.obj = obj;
|
||||||
|
done.signalAll();
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这样利用扩展后的GuardedObject来解决小灰同学的问题就很简单了,具体代码如下所示。
|
||||||
|
|
||||||
|
//处理浏览器发来的请求
|
||||||
|
Respond handleWebReq(){
|
||||||
|
int id=序号生成器.get();
|
||||||
|
//创建一消息
|
||||||
|
Message msg1 = new
|
||||||
|
Message(id,"{...}");
|
||||||
|
//创建GuardedObject实例
|
||||||
|
GuardedObject<Message> go=
|
||||||
|
GuardedObject.create(id);
|
||||||
|
//发送消息
|
||||||
|
send(msg1);
|
||||||
|
//等待MQ消息
|
||||||
|
Message r = go.get(
|
||||||
|
t->t != null);
|
||||||
|
}
|
||||||
|
void onMessage(Message msg){
|
||||||
|
//唤醒等待的线程
|
||||||
|
GuardedObject.fireEvent(
|
||||||
|
msg.id, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Guarded Suspension模式本质上是一种等待唤醒机制的实现,只不过Guarded Suspension模式将其规范化了。规范化的好处是你无需重头思考如何实现,也无需担心实现程序的可理解性问题,同时也能避免一不小心写出个Bug来。但Guarded Suspension模式在解决实际问题的时候,往往还是需要扩展的,扩展的方式有很多,本篇文章就直接对GuardedObject的功能进行了增强,Dubbo中DefaultFuture这个类也是采用的这种方式,你可以对比着来看,相信对DefaultFuture的实现原理会理解得更透彻。当然,你也可以创建新的类来实现对Guarded Suspension模式的扩展。
|
||||||
|
|
||||||
|
Guarded Suspension模式也常被称作Guarded Wait模式、Spin Lock模式(因为使用了while循环去等待),这些名字都很形象,不过它还有一个更形象的非官方名字:多线程版本的if。单线程场景中,if语句是不需要等待的,因为在只有一个线程的条件下,如果这个线程被阻塞,那就没有其他活动线程了,这意味着if判断条件的结果也不会发生变化了。但是多线程场景中,等待就变得有意义了,这种场景下,if判断条件的结果是可能发生变化的。所以,用“多线程版本的if”来理解这个模式会更简单。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
有同学觉得用done.await()还要加锁,太啰嗦,还不如直接使用sleep()方法,下面是他的实现,你觉得他的写法正确吗?
|
||||||
|
|
||||||
|
//获取受保护对象
|
||||||
|
T get(Predicate<T> p) {
|
||||||
|
try {
|
||||||
|
while(!p.test(obj)){
|
||||||
|
TimeUnit.SECONDS
|
||||||
|
.sleep(timeout);
|
||||||
|
}
|
||||||
|
}catch(InterruptedException e){
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
//返回非空的受保护对象
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
//事件通知方法
|
||||||
|
void onChanged(T obj) {
|
||||||
|
this.obj = obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
243
专栏/Java并发编程实战/32Balking模式:再谈线程安全的单例模式.md
Normal file
243
专栏/Java并发编程实战/32Balking模式:再谈线程安全的单例模式.md
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
32 Balking模式:再谈线程安全的单例模式
|
||||||
|
上一篇文章中,我们提到可以用“多线程版本的if”来理解Guarded Suspension模式,不同于单线程中的if,这个“多线程版本的if”是需要等待的,而且还很执着,必须要等到条件为真。但很显然这个世界,不是所有场景都需要这么执着,有时候我们还需要快速放弃。
|
||||||
|
|
||||||
|
需要快速放弃的一个最常见的例子是各种编辑器提供的自动保存功能。自动保存功能的实现逻辑一般都是隔一定时间自动执行存盘操作,存盘操作的前提是文件做过修改,如果文件没有执行过修改操作,就需要快速放弃存盘操作。下面的示例代码将自动保存功能代码化了,很显然AutoSaveEditor这个类不是线程安全的,因为对共享变量changed的读写没有使用同步,那如何保证AutoSaveEditor的线程安全性呢?
|
||||||
|
|
||||||
|
class AutoSaveEditor{
|
||||||
|
//文件是否被修改过
|
||||||
|
boolean changed=false;
|
||||||
|
//定时任务线程池
|
||||||
|
ScheduledExecutorService ses =
|
||||||
|
Executors.newSingleThreadScheduledExecutor();
|
||||||
|
//定时执行自动保存
|
||||||
|
void startAutoSave(){
|
||||||
|
ses.scheduleWithFixedDelay(()->{
|
||||||
|
autoSave();
|
||||||
|
}, 5, 5, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
//自动存盘操作
|
||||||
|
void autoSave(){
|
||||||
|
if (!changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changed = false;
|
||||||
|
//执行存盘操作
|
||||||
|
//省略且实现
|
||||||
|
this.execSave();
|
||||||
|
}
|
||||||
|
//编辑操作
|
||||||
|
void edit(){
|
||||||
|
//省略编辑逻辑
|
||||||
|
......
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
解决这个问题相信你一定手到擒来了:读写共享变量changed的方法autoSave()和edit()都加互斥锁就可以了。这样做虽然简单,但是性能很差,原因是锁的范围太大了。那我们可以将锁的范围缩小,只在读写共享变量changed的地方加锁,实现代码如下所示。
|
||||||
|
|
||||||
|
//自动存盘操作
|
||||||
|
void autoSave(){
|
||||||
|
synchronized(this){
|
||||||
|
if (!changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changed = false;
|
||||||
|
}
|
||||||
|
//执行存盘操作
|
||||||
|
//省略且实现
|
||||||
|
this.execSave();
|
||||||
|
}
|
||||||
|
//编辑操作
|
||||||
|
void edit(){
|
||||||
|
//省略编辑逻辑
|
||||||
|
......
|
||||||
|
synchronized(this){
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
如果你深入地分析一下这个示例程序,你会发现,示例中的共享变量是一个状态变量,业务逻辑依赖于这个状态变量的状态:当状态满足某个条件时,执行某个业务逻辑,其本质其实不过就是一个if而已,放到多线程场景里,就是一种“多线程版本的if”。这种“多线程版本的if”的应用场景还是很多的,所以也有人把它总结成了一种设计模式,叫做Balking模式。
|
||||||
|
|
||||||
|
Balking模式的经典实现
|
||||||
|
|
||||||
|
Balking模式本质上是一种规范化地解决“多线程版本的if”的方案,对于上面自动保存的例子,使用Balking模式规范化之后的写法如下所示,你会发现仅仅是将edit()方法中对共享变量changed的赋值操作抽取到了change()中,这样的好处是将并发处理逻辑和业务逻辑分开。
|
||||||
|
|
||||||
|
boolean changed=false;
|
||||||
|
//自动存盘操作
|
||||||
|
void autoSave(){
|
||||||
|
synchronized(this){
|
||||||
|
if (!changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changed = false;
|
||||||
|
}
|
||||||
|
//执行存盘操作
|
||||||
|
//省略且实现
|
||||||
|
this.execSave();
|
||||||
|
}
|
||||||
|
//编辑操作
|
||||||
|
void edit(){
|
||||||
|
//省略编辑逻辑
|
||||||
|
......
|
||||||
|
change();
|
||||||
|
}
|
||||||
|
//改变状态
|
||||||
|
void change(){
|
||||||
|
synchronized(this){
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
用volatile实现Balking模式
|
||||||
|
|
||||||
|
前面我们用synchronized实现了Balking模式,这种实现方式最为稳妥,建议你实际工作中也使用这个方案。不过在某些特定场景下,也可以使用volatile来实现,但使用volatile的前提是对原子性没有要求。
|
||||||
|
|
||||||
|
在《29 | Copy-on-Write模式:不是延时策略的COW》中,有一个RPC框架路由表的案例,在RPC框架中,本地路由表是要和注册中心进行信息同步的,应用启动的时候,会将应用依赖服务的路由表从注册中心同步到本地路由表中,如果应用重启的时候注册中心宕机,那么会导致该应用依赖的服务均不可用,因为找不到依赖服务的路由表。为了防止这种极端情况出现,RPC框架可以将本地路由表自动保存到本地文件中,如果重启的时候注册中心宕机,那么就从本地文件中恢复重启前的路由表。这其实也是一种降级的方案。
|
||||||
|
|
||||||
|
自动保存路由表和前面介绍的编辑器自动保存原理是一样的,也可以用Balking模式实现,不过我们这里采用volatile来实现,实现的代码如下所示。之所以可以采用volatile来实现,是因为对共享变量changed和rt的写操作不存在原子性的要求,而且采用scheduleWithFixedDelay()这种调度方式能保证同一时刻只有一个线程执行autoSave()方法。
|
||||||
|
|
||||||
|
//路由表信息
|
||||||
|
public class RouterTable {
|
||||||
|
//Key:接口名
|
||||||
|
//Value:路由集合
|
||||||
|
ConcurrentHashMap<String, CopyOnWriteArraySet<Router>>
|
||||||
|
rt = new ConcurrentHashMap<>();
|
||||||
|
//路由表是否发生变化
|
||||||
|
volatile boolean changed;
|
||||||
|
//将路由表写入本地文件的线程池
|
||||||
|
ScheduledExecutorService ses=
|
||||||
|
Executors.newSingleThreadScheduledExecutor();
|
||||||
|
//启动定时任务
|
||||||
|
//将变更后的路由表写入本地文件
|
||||||
|
public void startLocalSaver(){
|
||||||
|
ses.scheduleWithFixedDelay(()->{
|
||||||
|
autoSave();
|
||||||
|
}, 1, 1, MINUTES);
|
||||||
|
}
|
||||||
|
//保存路由表到本地文件
|
||||||
|
void autoSave() {
|
||||||
|
if (!changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changed = false;
|
||||||
|
//将路由表写入本地文件
|
||||||
|
//省略其方法实现
|
||||||
|
this.save2Local();
|
||||||
|
}
|
||||||
|
//删除路由
|
||||||
|
public void remove(Router router) {
|
||||||
|
Set<Router> set=rt.get(router.iface);
|
||||||
|
if (set != null) {
|
||||||
|
set.remove(router);
|
||||||
|
//路由表已发生变化
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//增加路由
|
||||||
|
public void add(Router router) {
|
||||||
|
Set<Router> set = rt.computeIfAbsent(
|
||||||
|
route.iface, r ->
|
||||||
|
new CopyOnWriteArraySet<>());
|
||||||
|
set.add(router);
|
||||||
|
//路由表已发生变化
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Balking模式有一个非常典型的应用场景就是单次初始化,下面的示例代码是它的实现。这个实现方案中,我们将init()声明为一个同步方法,这样同一个时刻就只有一个线程能够执行init()方法;init()方法在第一次执行完时会将inited设置为true,这样后续执行init()方法的线程就不会再执行doInit()了。
|
||||||
|
|
||||||
|
class InitTest{
|
||||||
|
boolean inited = false;
|
||||||
|
synchronized void init(){
|
||||||
|
if(inited){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
//省略doInit的实现
|
||||||
|
doInit();
|
||||||
|
inited=true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
线程安全的单例模式本质上其实也是单次初始化,所以可以用Balking模式来实现线程安全的单例模式,下面的示例代码是其实现。这个实现虽然功能上没有问题,但是性能却很差,因为互斥锁synchronized将getInstance()方法串行化了,那有没有办法可以优化一下它的性能呢?
|
||||||
|
|
||||||
|
class Singleton{
|
||||||
|
private static
|
||||||
|
Singleton singleton;
|
||||||
|
//构造方法私有化
|
||||||
|
private Singleton(){}
|
||||||
|
//获取实例(单例)
|
||||||
|
public synchronized static
|
||||||
|
Singleton getInstance(){
|
||||||
|
if(singleton == null){
|
||||||
|
singleton=new Singleton();
|
||||||
|
}
|
||||||
|
return singleton;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
办法当然是有的,那就是经典的双重检查(Double Check)方案,下面的示例代码是其详细实现。在双重检查方案中,一旦Singleton对象被成功创建之后,就不会执行synchronized(Singleton.class){}相关的代码,也就是说,此时getInstance()方法的执行路径是无锁的,从而解决了性能问题。不过需要你注意的是,这个方案中使用了volatile来禁止编译优化,其原因你可以参考《01 | 可见性、原子性和有序性问题:并发编程Bug的源头》中相关的内容。至于获取锁后的二次检查,则是出于对安全性负责。
|
||||||
|
|
||||||
|
class Singleton{
|
||||||
|
private static volatile
|
||||||
|
Singleton singleton;
|
||||||
|
//构造方法私有化
|
||||||
|
private Singleton() {}
|
||||||
|
//获取实例(单例)
|
||||||
|
public static Singleton
|
||||||
|
getInstance() {
|
||||||
|
//第一次检查
|
||||||
|
if(singleton==null){
|
||||||
|
synchronize(Singleton.class){
|
||||||
|
//获取锁后二次检查
|
||||||
|
if(singleton==null){
|
||||||
|
singleton=new Singleton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return singleton;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Balking模式和Guarded Suspension模式从实现上看似乎没有多大的关系,Balking模式只需要用互斥锁就能解决,而Guarded Suspension模式则要用到管程这种高级的并发原语;但是从应用的角度来看,它们解决的都是“线程安全的if”语义,不同之处在于,Guarded Suspension模式会等待if条件为真,而Balking模式不会等待。
|
||||||
|
|
||||||
|
Balking模式的经典实现是使用互斥锁,你可以使用Java语言内置synchronized,也可以使用SDK提供Lock;如果你对互斥锁的性能不满意,可以尝试采用volatile方案,不过使用volatile方案需要你更加谨慎。
|
||||||
|
|
||||||
|
当然你也可以尝试使用双重检查方案来优化性能,双重检查中的第一次检查,完全是出于对性能的考量:避免执行加锁操作,因为加锁操作很耗时。而加锁之后的二次检查,则是出于对安全性负责。双重检查方案在优化加锁性能方面经常用到,例如《17 | ReadWriteLock:如何快速实现一个完备的缓存?》中实现缓存按需加载功能时,也用到了双重检查方案。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
下面的示例代码中,init()方法的本意是:仅需计算一次count的值,采用了Balking模式的volatile实现方式,你觉得这个实现是否有问题呢?
|
||||||
|
|
||||||
|
class Test{
|
||||||
|
volatile boolean inited = false;
|
||||||
|
int count = 0;
|
||||||
|
void init(){
|
||||||
|
if(inited){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inited = true;
|
||||||
|
//计算count的值
|
||||||
|
count = calc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
166
专栏/Java并发编程实战/33Thread-Per-Message模式:最简单实用的分工方法.md
Normal file
166
专栏/Java并发编程实战/33Thread-Per-Message模式:最简单实用的分工方法.md
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
33 Thread-Per-Message模式:最简单实用的分工方法
|
||||||
|
我们曾经把并发编程领域的问题总结为三个核心问题:分工、同步和互斥。其中,同步和互斥相关问题更多地源自微观,而分工问题则是源自宏观。我们解决问题,往往都是从宏观入手,在编程领域,软件的设计过程也是先从概要设计开始,而后才进行详细设计。同样,解决并发编程问题,首要问题也是解决宏观的分工问题。
|
||||||
|
|
||||||
|
并发编程领域里,解决分工问题也有一系列的设计模式,比较常用的主要有Thread-Per-Message模式、Worker Thread模式、生产者-消费者模式等等。今天我们重点介绍Thread-Per-Message模式。
|
||||||
|
|
||||||
|
如何理解Thread-Per-Message模式
|
||||||
|
|
||||||
|
现实世界里,很多事情我们都需要委托他人办理,一方面受限于我们的能力,总有很多搞不定的事,比如教育小朋友,搞不定怎么办呢?只能委托学校老师了;另一方面受限于我们的时间,比如忙着写Bug,哪有时间买别墅呢?只能委托房产中介了。委托他人代办有一个非常大的好处,那就是可以专心做自己的事了。
|
||||||
|
|
||||||
|
在编程领域也有很多类似的需求,比如写一个HTTP Server,很显然只能在主线程中接收请求,而不能处理HTTP请求,因为如果在主线程中处理HTTP请求的话,那同一时间只能处理一个请求,太慢了!怎么办呢?可以利用代办的思路,创建一个子线程,委托子线程去处理HTTP请求。
|
||||||
|
|
||||||
|
这种委托他人办理的方式,在并发编程领域被总结为一种设计模式,叫做Thread-Per-Message模式,简言之就是为每个任务分配一个独立的线程。这是一种最简单的分工方法,实现起来也非常简单。
|
||||||
|
|
||||||
|
用Thread实现Thread-Per-Message模式
|
||||||
|
|
||||||
|
Thread-Per-Message模式的一个最经典的应用场景是网络编程里服务端的实现,服务端为每个客户端请求创建一个独立的线程,当线程处理完请求后,自动销毁,这是一种最简单的并发处理网络请求的方法。
|
||||||
|
|
||||||
|
网络编程里最简单的程序当数echo程序了,echo程序的服务端会原封不动地将客户端的请求发送回客户端。例如,客户端发送TCP请求”Hello World”,那么服务端也会返回”Hello World”。
|
||||||
|
|
||||||
|
下面我们就以echo程序的服务端为例,介绍如何实现Thread-Per-Message模式。
|
||||||
|
|
||||||
|
在Java语言中,实现echo程序的服务端还是很简单的。只需要30行代码就能够实现,示例代码如下,我们为每个请求都创建了一个Java线程,核心代码是:new Thread(()->{…}).start()。
|
||||||
|
|
||||||
|
final ServerSocketChannel =
|
||||||
|
ServerSocketChannel.open().bind(
|
||||||
|
new InetSocketAddress(8080));
|
||||||
|
//处理请求
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
// 接收请求
|
||||||
|
SocketChannel sc = ssc.accept();
|
||||||
|
// 每个请求都创建一个线程
|
||||||
|
new Thread(()->{
|
||||||
|
try {
|
||||||
|
// 读Socket
|
||||||
|
ByteBuffer rb = ByteBuffer
|
||||||
|
.allocateDirect(1024);
|
||||||
|
sc.read(rb);
|
||||||
|
//模拟处理请求
|
||||||
|
Thread.sleep(2000);
|
||||||
|
// 写Socket
|
||||||
|
ByteBuffer wb =
|
||||||
|
(ByteBuffer)rb.flip();
|
||||||
|
sc.write(wb);
|
||||||
|
// 关闭Socket
|
||||||
|
sc.close();
|
||||||
|
}catch(Exception e){
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ssc.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
如果你熟悉网络编程,相信你一定会提出一个很尖锐的问题:上面这个echo服务的实现方案是不具备可行性的。原因在于Java中的线程是一个重量级的对象,创建成本很高,一方面创建线程比较耗时,另一方面线程占用的内存也比较大。所以,为每个请求创建一个新的线程并不适合高并发场景。
|
||||||
|
|
||||||
|
于是,你开始质疑Thread-Per-Message模式,而且开始重新思索解决方案,这时候很可能你会想到Java提供的线程池。你的这个思路没有问题,但是引入线程池难免会增加复杂度。其实你完全可以换一个角度来思考这个问题,语言、工具、框架本身应该是帮助我们更敏捷地实现方案的,而不是用来否定方案的,Thread-Per-Message模式作为一种最简单的分工方案,Java语言支持不了,显然是Java语言本身的问题。
|
||||||
|
|
||||||
|
Java语言里,Java线程是和操作系统线程一一对应的,这种做法本质上是将Java线程的调度权完全委托给操作系统,而操作系统在这方面非常成熟,所以这种做法的好处是稳定、可靠,但是也继承了操作系统线程的缺点:创建成本高。为了解决这个缺点,Java并发包里提供了线程池等工具类。这个思路在很长一段时间里都是很稳妥的方案,但是这个方案并不是唯一的方案。
|
||||||
|
|
||||||
|
业界还有另外一种方案,叫做轻量级线程。这个方案在Java领域知名度并不高,但是在其他编程语言里却叫得很响,例如Go语言、Lua语言里的协程,本质上就是一种轻量级的线程。轻量级的线程,创建的成本很低,基本上和创建一个普通对象的成本相似;并且创建的速度和内存占用相比操作系统线程至少有一个数量级的提升,所以基于轻量级线程实现Thread-Per-Message模式就完全没有问题了。
|
||||||
|
|
||||||
|
Java语言目前也已经意识到轻量级线程的重要性了,OpenJDK有个Loom项目,就是要解决Java语言的轻量级线程问题,在这个项目中,轻量级线程被叫做Fiber。下面我们就来看看基于Fiber如何实现Thread-Per-Message模式。
|
||||||
|
|
||||||
|
用Fiber实现Thread-Per-Message模式
|
||||||
|
|
||||||
|
Loom项目在设计轻量级线程时,充分考量了当前Java线程的使用方式,采取的是尽量兼容的态度,所以使用上还是挺简单的。用Fiber实现echo服务的示例代码如下所示,对比Thread的实现,你会发现改动量非常小,只需要把new Thread(()->{…}).start()换成 Fiber.schedule(()->{})就可以了。
|
||||||
|
|
||||||
|
final ServerSocketChannel ssc =
|
||||||
|
ServerSocketChannel.open().bind(
|
||||||
|
new InetSocketAddress(8080));
|
||||||
|
//处理请求
|
||||||
|
try{
|
||||||
|
while (true) {
|
||||||
|
// 接收请求
|
||||||
|
final SocketChannel sc =
|
||||||
|
ssc.accept();
|
||||||
|
Fiber.schedule(()->{
|
||||||
|
try {
|
||||||
|
// 读Socket
|
||||||
|
ByteBuffer rb = ByteBuffer
|
||||||
|
.allocateDirect(1024);
|
||||||
|
sc.read(rb);
|
||||||
|
//模拟处理请求
|
||||||
|
LockSupport.parkNanos(2000*1000000);
|
||||||
|
// 写Socket
|
||||||
|
ByteBuffer wb =
|
||||||
|
(ByteBuffer)rb.flip()
|
||||||
|
sc.write(wb);
|
||||||
|
// 关闭Socket
|
||||||
|
sc.close();
|
||||||
|
} catch(Exception e){
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}//while
|
||||||
|
}finally{
|
||||||
|
ssc.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
那使用Fiber实现的echo服务是否能够达到预期的效果呢?我们可以在Linux环境下做一个简单的实验,步骤如下:
|
||||||
|
|
||||||
|
|
||||||
|
首先通过 ulimit -u 512 将用户能创建的最大进程数(包括线程)设置为512;
|
||||||
|
启动Fiber实现的echo程序;
|
||||||
|
利用压测工具ab进行压测:ab -r -c 20000 -n 200000 http://测试机IP地址:8080/
|
||||||
|
|
||||||
|
|
||||||
|
压测执行结果如下:
|
||||||
|
|
||||||
|
Concurrency Level: 20000
|
||||||
|
Time taken for tests: 67.718 seconds
|
||||||
|
Complete requests: 200000
|
||||||
|
Failed requests: 0
|
||||||
|
Write errors: 0
|
||||||
|
Non-2xx responses: 200000
|
||||||
|
Total transferred: 16400000 bytes
|
||||||
|
HTML transferred: 0 bytes
|
||||||
|
Requests per second: 2953.41 [#/sec] (mean)
|
||||||
|
Time per request: 6771.844 [ms] (mean)
|
||||||
|
Time per request: 0.339 [ms] (mean, across all concurrent requests)
|
||||||
|
Transfer rate: 236.50 [Kbytes/sec] received
|
||||||
|
|
||||||
|
Connection Times (ms)
|
||||||
|
min mean[+/-sd] median max
|
||||||
|
Connect: 0 557 3541.6 1 63127
|
||||||
|
Processing: 2000 2010 31.8 2003 2615
|
||||||
|
Waiting: 1986 2008 30.9 2002 2615
|
||||||
|
Total: 2000 2567 3543.9 2004 65293
|
||||||
|
|
||||||
|
|
||||||
|
你会发现即便在20000并发下,该程序依然能够良好运行。同等条件下,Thread实现的echo程序512并发都抗不过去,直接就OOM了。
|
||||||
|
|
||||||
|
如果你通过Linux命令 top -Hp pid 查看Fiber实现的echo程序的进程信息,你可以看到该进程仅仅创建了16(不同CPU核数结果会不同)个操作系统线程。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
如果你对Loom项目感兴趣,也想上手试一把,可以下载源代码自己构建,构建方法可以参考Project Loom的相关资料,不过需要注意的是构建之前一定要把代码分支切换到Fibers。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
并发编程领域的分工问题,指的是如何高效地拆解任务并分配给线程。前面我们在并发工具类模块中已经介绍了不少解决分工问题的工具类,例如Future、CompletableFuture 、CompletionService、Fork/Join计算框架等,这些工具类都能很好地解决特定应用场景的问题,所以,这些工具类曾经是Java语言引以为傲的。不过这些工具类都继承了Java语言的老毛病:太复杂。
|
||||||
|
|
||||||
|
如果你一直从事Java开发,估计你已经习以为常了,习惯性地认为这个复杂度是正常的。不过这个世界时刻都在变化,曾经正常的复杂度,现在看来也许就已经没有必要了,例如Thread-Per-Message模式如果使用线程池方案就会增加复杂度。
|
||||||
|
|
||||||
|
Thread-Per-Message模式在Java领域并不是那么知名,根本原因在于Java语言里的线程是一个重量级的对象,为每一个任务创建一个线程成本太高,尤其是在高并发领域,基本就不具备可行性。不过这个背景条件目前正在发生巨变,Java语言未来一定会提供轻量级线程,这样基于轻量级线程实现Thread-Per-Message模式就是一个非常靠谱的选择。
|
||||||
|
|
||||||
|
当然,对于一些并发度没那么高的异步场景,例如定时任务,采用Thread-Per-Message模式是完全没有问题的。实际工作中,我就见过完全基于Thread-Per-Message模式实现的分布式调度框架,这个框架为每个定时任务都分配了一个独立的线程。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
使用Thread-Per-Message模式会为每一个任务都创建一个线程,在高并发场景中,很容易导致应用OOM,那有什么办法可以快速解决呢?
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
163
专栏/Java并发编程实战/34WorkerThread模式:如何避免重复创建线程?.md
Normal file
163
专栏/Java并发编程实战/34WorkerThread模式:如何避免重复创建线程?.md
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
34 Worker Thread模式:如何避免重复创建线程?
|
||||||
|
在上一篇文章中,我们介绍了一种最简单的分工模式——Thread-Per-Message模式,对应到现实世界,其实就是委托代办。这种分工模式如果用Java Thread实现,频繁地创建、销毁线程非常影响性能,同时无限制地创建线程还可能导致OOM,所以在Java领域使用场景就受限了。
|
||||||
|
|
||||||
|
要想有效避免线程的频繁创建、销毁以及OOM问题,就不得不提今天我们要细聊的,也是Java领域使用最多的Worker Thread模式。
|
||||||
|
|
||||||
|
Worker Thread模式及其实现
|
||||||
|
|
||||||
|
Worker Thread模式可以类比现实世界里车间的工作模式:车间里的工人,有活儿了,大家一起干,没活儿了就聊聊天等着。你可以参考下面的示意图来理解,Worker Thread模式中Worker Thread对应到现实世界里,其实指的就是车间里的工人。不过这里需要注意的是,车间里的工人数量往往是确定的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
车间工作示意图
|
||||||
|
|
||||||
|
那在编程领域该如何模拟车间的这种工作模式呢?或者说如何去实现Worker Thread模式呢?通过上面的图,你很容易就能想到用阻塞队列做任务池,然后创建固定数量的线程消费阻塞队列中的任务。其实你仔细想会发现,这个方案就是Java语言提供的线程池。
|
||||||
|
|
||||||
|
线程池有很多优点,例如能够避免重复创建、销毁线程,同时能够限制创建线程的上限等等。学习完上一篇文章后你已经知道,用Java的Thread实现Thread-Per-Message模式难以应对高并发场景,原因就在于频繁创建、销毁Java线程的成本有点高,而且无限制地创建线程还可能导致应用OOM。线程池,则恰好能解决这些问题。
|
||||||
|
|
||||||
|
那我们还是以echo程序为例,看看如何用线程池来实现。
|
||||||
|
|
||||||
|
下面的示例代码是用线程池实现的echo服务端,相比于Thread-Per-Message模式的实现,改动非常少,仅仅是创建了一个最多线程数为500的线程池es,然后通过es.execute()方法将请求处理的任务提交给线程池处理。
|
||||||
|
|
||||||
|
ExecutorService es = Executors
|
||||||
|
.newFixedThreadPool(500);
|
||||||
|
final ServerSocketChannel ssc =
|
||||||
|
ServerSocketChannel.open().bind(
|
||||||
|
new InetSocketAddress(8080));
|
||||||
|
//处理请求
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
// 接收请求
|
||||||
|
SocketChannel sc = ssc.accept();
|
||||||
|
// 将请求处理任务提交给线程池
|
||||||
|
es.execute(()->{
|
||||||
|
try {
|
||||||
|
// 读Socket
|
||||||
|
ByteBuffer rb = ByteBuffer
|
||||||
|
.allocateDirect(1024);
|
||||||
|
sc.read(rb);
|
||||||
|
//模拟处理请求
|
||||||
|
Thread.sleep(2000);
|
||||||
|
// 写Socket
|
||||||
|
ByteBuffer wb =
|
||||||
|
(ByteBuffer)rb.flip();
|
||||||
|
sc.write(wb);
|
||||||
|
// 关闭Socket
|
||||||
|
sc.close();
|
||||||
|
}catch(Exception e){
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ssc.close();
|
||||||
|
es.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
正确地创建线程池
|
||||||
|
|
||||||
|
Java的线程池既能够避免无限制地创建线程导致OOM,也能避免无限制地接收任务导致OOM。只不过后者经常容易被我们忽略,例如在上面的实现中,就被我们忽略了。所以强烈建议你用创建有界的队列来接收任务。
|
||||||
|
|
||||||
|
当请求量大于有界队列的容量时,就需要合理地拒绝请求。如何合理地拒绝呢?这需要你结合具体的业务场景来制定,即便线程池默认的拒绝策略能够满足你的需求,也同样建议你在创建线程池时,清晰地指明拒绝策略。
|
||||||
|
|
||||||
|
同时,为了便于调试和诊断问题,我也强烈建议你在实际工作中给线程赋予一个业务相关的名字。
|
||||||
|
|
||||||
|
综合以上这三点建议,echo程序中创建线程可以使用下面的示例代码。
|
||||||
|
|
||||||
|
ExecutorService es = new ThreadPoolExecutor(
|
||||||
|
50, 500,
|
||||||
|
60L, TimeUnit.SECONDS,
|
||||||
|
//注意要创建有界队列
|
||||||
|
new LinkedBlockingQueue<Runnable>(2000),
|
||||||
|
//建议根据业务需求实现ThreadFactory
|
||||||
|
r->{
|
||||||
|
return new Thread(r, "echo-"+ r.hashCode());
|
||||||
|
},
|
||||||
|
//建议根据业务需求实现RejectedExecutionHandler
|
||||||
|
new ThreadPoolExecutor.CallerRunsPolicy());
|
||||||
|
|
||||||
|
|
||||||
|
避免线程死锁
|
||||||
|
|
||||||
|
使用线程池过程中,还要注意一种线程死锁的场景。如果提交到相同线程池的任务不是相互独立的,而是有依赖关系的,那么就有可能导致线程死锁。实际工作中,我就亲历过这种线程死锁的场景。具体现象是应用每运行一段时间偶尔就会处于无响应的状态,监控数据看上去一切都正常,但是实际上已经不能正常工作了。
|
||||||
|
|
||||||
|
这个出问题的应用,相关的逻辑精简之后,如下图所示,该应用将一个大型的计算任务分成两个阶段,第一个阶段的任务会等待第二阶段的子任务完成。在这个应用里,每一个阶段都使用了线程池,而且两个阶段使用的还是同一个线程池。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
应用业务逻辑示意图
|
||||||
|
|
||||||
|
我们可以用下面的示例代码来模拟该应用,如果你执行下面的这段代码,会发现它永远执行不到最后一行。执行过程中没有任何异常,但是应用已经停止响应了。
|
||||||
|
|
||||||
|
//L1、L2阶段共用的线程池
|
||||||
|
ExecutorService es = Executors.
|
||||||
|
newFixedThreadPool(2);
|
||||||
|
//L1阶段的闭锁
|
||||||
|
CountDownLatch l1=new CountDownLatch(2);
|
||||||
|
for (int i=0; i<2; i++){
|
||||||
|
System.out.println("L1");
|
||||||
|
//执行L1阶段任务
|
||||||
|
es.execute(()->{
|
||||||
|
//L2阶段的闭锁
|
||||||
|
CountDownLatch l2=new CountDownLatch(2);
|
||||||
|
//执行L2阶段子任务
|
||||||
|
for (int j=0; j<2; j++){
|
||||||
|
es.execute(()->{
|
||||||
|
System.out.println("L2");
|
||||||
|
l2.countDown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//等待L2阶段任务执行完
|
||||||
|
l2.await();
|
||||||
|
l1.countDown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//等着L1阶段任务执行完
|
||||||
|
l1.await();
|
||||||
|
System.out.println("end");
|
||||||
|
|
||||||
|
|
||||||
|
当应用出现类似问题时,首选的诊断方法是查看线程栈。下图是上面示例代码停止响应后的线程栈,你会发现线程池中的两个线程全部都阻塞在 l2.await(); 这行代码上了,也就是说,线程池里所有的线程都在等待L2阶段的任务执行完,那L2阶段的子任务什么时候能够执行完呢?永远都没那一天了,为什么呢?因为线程池里的线程都阻塞了,没有空闲的线程执行L2阶段的任务了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
原因找到了,那如何解决就简单了,最简单粗暴的办法就是将线程池的最大线程数调大,如果能够确定任务的数量不是非常多的话,这个办法也是可行的,否则这个办法就行不通了。其实这种问题通用的解决方案是为不同的任务创建不同的线程池。对于上面的这个应用,L1阶段的任务和L2阶段的任务如果各自都有自己的线程池,就不会出现这种问题了。
|
||||||
|
|
||||||
|
最后再次强调一下:提交到相同线程池中的任务一定是相互独立的,否则就一定要慎重。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
我们曾经说过,解决并发编程里的分工问题,最好的办法是和现实世界做对比。对比现实世界构建编程领域的模型,能够让模型更容易理解。上一篇我们介绍的Thread-Per-Message模式,类似于现实世界里的委托他人办理,而今天介绍的Worker Thread模式则类似于车间里工人的工作模式。如果你在设计阶段,发现对业务模型建模之后,模型非常类似于车间的工作模式,那基本上就能确定可以在实现阶段采用Worker Thread模式来实现。
|
||||||
|
|
||||||
|
Worker Thread模式和Thread-Per-Message模式的区别有哪些呢?从现实世界的角度看,你委托代办人做事,往往是和代办人直接沟通的;对应到编程领域,其实现也是主线程直接创建了一个子线程,主子线程之间是可以直接通信的。而车间工人的工作方式则是完全围绕任务展开的,一个具体的任务被哪个工人执行,预先是无法知道的;对应到编程领域,则是主线程提交任务到线程池,但主线程并不关心任务被哪个线程执行。
|
||||||
|
|
||||||
|
Worker Thread模式能避免线程频繁创建、销毁的问题,而且能够限制线程的最大数量。Java语言里可以直接使用线程池来实现Worker Thread模式,线程池是一个非常基础和优秀的工具类,甚至有些大厂的编码规范都不允许用new Thread()来创建线程的,必须使用线程池。
|
||||||
|
|
||||||
|
不过使用线程池还是需要格外谨慎的,除了今天重点讲到的如何正确创建线程池、如何避免线程死锁问题,还需要注意前面我们曾经提到的ThreadLocal内存泄露问题。同时对于提交到线程池的任务,还要做好异常处理,避免异常的任务从眼前溜走,从业务的角度看,有时没有发现异常的任务后果往往都很严重。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
小灰同学写了如下的代码,本义是异步地打印字符串“QQ”,请问他的实现是否有问题呢?
|
||||||
|
|
||||||
|
ExecutorService pool = Executors
|
||||||
|
.newSingleThreadExecutor();
|
||||||
|
pool.submit(() -> {
|
||||||
|
try {
|
||||||
|
String qq=pool.submit(()->"QQ").get();
|
||||||
|
System.out.println(qq);
|
||||||
|
} catch (Exception e) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
197
专栏/Java并发编程实战/35两阶段终止模式:如何优雅地终止线程?.md
Normal file
197
专栏/Java并发编程实战/35两阶段终止模式:如何优雅地终止线程?.md
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
35 两阶段终止模式:如何优雅地终止线程?
|
||||||
|
前面两篇文章我们讲述的内容,从纯技术的角度看,都是启动多线程去执行一个异步任务。既启动,那又该如何终止呢?今天咱们就从技术的角度聊聊如何优雅地终止线程,正所谓有始有终。
|
||||||
|
|
||||||
|
在《09 | Java线程(上):Java线程的生命周期》中,我曾讲过:线程执行完或者出现异常就会进入终止状态。这样看,终止一个线程看上去很简单啊!一个线程执行完自己的任务,自己进入终止状态,这的确很简单。不过我们今天谈到的“优雅地终止线程”,不是自己终止自己,而是在一个线程T1中,终止线程T2;这里所谓的“优雅”,指的是给T2一个机会料理后事,而不是被一剑封喉。
|
||||||
|
|
||||||
|
Java语言的Thread类中曾经提供了一个stop()方法,用来终止线程,可是早已不建议使用了,原因是这个方法用的就是一剑封喉的做法,被终止的线程没有机会料理后事。
|
||||||
|
|
||||||
|
既然不建议使用stop()方法,那在Java领域,我们又该如何优雅地终止线程呢?
|
||||||
|
|
||||||
|
如何理解两阶段终止模式
|
||||||
|
|
||||||
|
前辈们经过认真对比分析,已经总结出了一套成熟的方案,叫做两阶段终止模式。顾名思义,就是将终止过程分成两个阶段,其中第一个阶段主要是线程T1向线程T2发送终止指令,而第二阶段则是线程T2响应终止指令。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
两阶段终止模式示意图
|
||||||
|
|
||||||
|
那在Java语言里,终止指令是什么呢?这个要从Java线程的状态转换过程说起。我们在《09 | Java线程(上):Java线程的生命周期》中曾经提到过Java线程的状态转换图,如下图所示。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Java中的线程状态转换图
|
||||||
|
|
||||||
|
从这个图里你会发现,Java线程进入终止状态的前提是线程进入RUNNABLE状态,而实际上线程也可能处在休眠状态,也就是说,我们要想终止一个线程,首先要把线程的状态从休眠状态转换到RUNNABLE状态。如何做到呢?这个要靠Java Thread类提供的interrupt()方法,它可以将休眠状态的线程转换到RUNNABLE状态。
|
||||||
|
|
||||||
|
线程转换到RUNNABLE状态之后,我们如何再将其终止呢?RUNNABLE状态转换到终止状态,优雅的方式是让Java线程自己执行完 run() 方法,所以一般我们采用的方法是设置一个标志位,然后线程会在合适的时机检查这个标志位,如果发现符合终止条件,则自动退出run()方法。这个过程其实就是我们前面提到的第二阶段:响应终止指令。
|
||||||
|
|
||||||
|
综合上面这两点,我们能总结出终止指令,其实包括两方面内容:interrupt()方法和线程终止的标志位。
|
||||||
|
|
||||||
|
理解了两阶段终止模式之后,下面我们看一个实际工作中的案例。
|
||||||
|
|
||||||
|
用两阶段终止模式终止监控操作
|
||||||
|
|
||||||
|
实际工作中,有些监控系统需要动态地采集一些数据,一般都是监控系统发送采集指令给被监控系统的监控代理,监控代理接收到指令之后,从监控目标收集数据,然后回传给监控系统,详细过程如下图所示。出于对性能的考虑(有些监控项对系统性能影响很大,所以不能一直持续监控),动态采集功能一般都会有终止操作。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
动态采集功能示意图
|
||||||
|
|
||||||
|
下面的示例代码是监控代理简化之后的实现,start()方法会启动一个新的线程rptThread来执行监控数据采集和回传的功能,stop()方法需要优雅地终止线程rptThread,那stop()相关功能该如何实现呢?
|
||||||
|
|
||||||
|
class Proxy {
|
||||||
|
boolean started = false;
|
||||||
|
//采集线程
|
||||||
|
Thread rptThread;
|
||||||
|
//启动采集功能
|
||||||
|
synchronized void start(){
|
||||||
|
//不允许同时启动多个采集线程
|
||||||
|
if (started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
started = true;
|
||||||
|
rptThread = new Thread(()->{
|
||||||
|
while (true) {
|
||||||
|
//省略采集、回传实现
|
||||||
|
report();
|
||||||
|
//每隔两秒钟采集、回传一次数据
|
||||||
|
try {
|
||||||
|
Thread.sleep(2000);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//执行到此处说明线程马上终止
|
||||||
|
started = false;
|
||||||
|
});
|
||||||
|
rptThread.start();
|
||||||
|
}
|
||||||
|
//终止采集功能
|
||||||
|
synchronized void stop(){
|
||||||
|
//如何实现?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
按照两阶段终止模式,我们首先需要做的就是将线程rptThread状态转换到RUNNABLE,做法很简单,只需要在调用 rptThread.interrupt() 就可以了。线程rptThread的状态转换到RUNNABLE之后,如何优雅地终止呢?下面的示例代码中,我们选择的标志位是线程的中断状态:Thread.currentThread().isInterrupted() ,需要注意的是,我们在捕获Thread.sleep()的中断异常之后,通过 Thread.currentThread().interrupt() 重新设置了线程的中断状态,因为JVM的异常处理会清除线程的中断状态。
|
||||||
|
|
||||||
|
class Proxy {
|
||||||
|
boolean started = false;
|
||||||
|
//采集线程
|
||||||
|
Thread rptThread;
|
||||||
|
//启动采集功能
|
||||||
|
synchronized void start(){
|
||||||
|
//不允许同时启动多个采集线程
|
||||||
|
if (started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
started = true;
|
||||||
|
rptThread = new Thread(()->{
|
||||||
|
while (!Thread.currentThread().isInterrupted()){
|
||||||
|
//省略采集、回传实现
|
||||||
|
report();
|
||||||
|
//每隔两秒钟采集、回传一次数据
|
||||||
|
try {
|
||||||
|
Thread.sleep(2000);
|
||||||
|
} catch (InterruptedException e){
|
||||||
|
//重新设置线程中断状态
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//执行到此处说明线程马上终止
|
||||||
|
started = false;
|
||||||
|
});
|
||||||
|
rptThread.start();
|
||||||
|
}
|
||||||
|
//终止采集功能
|
||||||
|
synchronized void stop(){
|
||||||
|
rptThread.interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
上面的示例代码的确能够解决当前的问题,但是建议你在实际工作中谨慎使用。原因在于我们很可能在线程的run()方法中调用第三方类库提供的方法,而我们没有办法保证第三方类库正确处理了线程的中断异常,例如第三方类库在捕获到Thread.sleep()方法抛出的中断异常后,没有重新设置线程的中断状态,那么就会导致线程不能够正常终止。所以强烈建议你设置自己的线程终止标志位,例如在下面的代码中,使用isTerminated作为线程终止标志位,此时无论是否正确处理了线程的中断异常,都不会影响线程优雅地终止。
|
||||||
|
|
||||||
|
class Proxy {
|
||||||
|
//线程终止标志位
|
||||||
|
volatile boolean terminated = false;
|
||||||
|
boolean started = false;
|
||||||
|
//采集线程
|
||||||
|
Thread rptThread;
|
||||||
|
//启动采集功能
|
||||||
|
synchronized void start(){
|
||||||
|
//不允许同时启动多个采集线程
|
||||||
|
if (started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
started = true;
|
||||||
|
terminated = false;
|
||||||
|
rptThread = new Thread(()->{
|
||||||
|
while (!terminated){
|
||||||
|
//省略采集、回传实现
|
||||||
|
report();
|
||||||
|
//每隔两秒钟采集、回传一次数据
|
||||||
|
try {
|
||||||
|
Thread.sleep(2000);
|
||||||
|
} catch (InterruptedException e){
|
||||||
|
//重新设置线程中断状态
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//执行到此处说明线程马上终止
|
||||||
|
started = false;
|
||||||
|
});
|
||||||
|
rptThread.start();
|
||||||
|
}
|
||||||
|
//终止采集功能
|
||||||
|
synchronized void stop(){
|
||||||
|
//设置中断标志位
|
||||||
|
terminated = true;
|
||||||
|
//中断线程rptThread
|
||||||
|
rptThread.interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
如何优雅地终止线程池
|
||||||
|
|
||||||
|
Java领域用的最多的还是线程池,而不是手动地创建线程。那我们该如何优雅地终止线程池呢?
|
||||||
|
|
||||||
|
线程池提供了两个方法:shutdown()和shutdownNow()。这两个方法有什么区别呢?要了解它们的区别,就先需要了解线程池的实现原理。
|
||||||
|
|
||||||
|
我们曾经讲过,Java线程池是生产者-消费者模式的一种实现,提交给线程池的任务,首先是进入一个阻塞队列中,之后线程池中的线程从阻塞队列中取出任务执行。
|
||||||
|
|
||||||
|
shutdown()方法是一种很保守的关闭线程池的方法。线程池执行shutdown()后,就会拒绝接收新的任务,但是会等待线程池中正在执行的任务和已经进入阻塞队列的任务都执行完之后才最终关闭线程池。
|
||||||
|
|
||||||
|
而shutdownNow()方法,相对就激进一些了,线程池执行shutdownNow()后,会拒绝接收新的任务,同时还会中断线程池中正在执行的任务,已经进入阻塞队列的任务也被剥夺了执行的机会,不过这些被剥夺执行机会的任务会作为shutdownNow()方法的返回值返回。因为shutdownNow()方法会中断正在执行的线程,所以提交到线程池的任务,如果需要优雅地结束,就需要正确地处理线程中断。
|
||||||
|
|
||||||
|
如果提交到线程池的任务不允许取消,那就不能使用shutdownNow()方法终止线程池。不过,如果提交到线程池的任务允许后续以补偿的方式重新执行,也是可以使用shutdownNow()方法终止线程池的。《Java并发编程实战》这本书第7章《取消与关闭》的“shutdownNow的局限性”一节中,提到一种将已提交但尚未开始执行的任务以及已经取消的正在执行的任务保存起来,以便后续重新执行的方案,你可以参考一下,方案很简单,这里就不详细介绍了。
|
||||||
|
|
||||||
|
其实分析完shutdown()和shutdownNow()方法你会发现,它们实质上使用的也是两阶段终止模式,只是终止指令的范围不同而已,前者只影响阻塞队列接收任务,后者范围扩大到线程池中所有的任务。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
两阶段终止模式是一种应用很广泛的并发设计模式,在Java语言中使用两阶段终止模式来优雅地终止线程,需要注意两个关键点:一个是仅检查终止标志位是不够的,因为线程的状态可能处于休眠态;另一个是仅检查线程的中断状态也是不够的,因为我们依赖的第三方类库很可能没有正确处理中断异常。
|
||||||
|
|
||||||
|
当你使用Java的线程池来管理线程的时候,需要依赖线程池提供的shutdown()和shutdownNow()方法来终止线程池。不过在使用时需要注意它们的应用场景,尤其是在使用shutdownNow()的时候,一定要谨慎。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
本文的示例代码中,线程终止标志位isTerminated被声明为volatile,你觉得是否有必要呢?
|
||||||
|
|
||||||
|
class Proxy {
|
||||||
|
//线程终止标志位
|
||||||
|
volatile boolean terminated = false;
|
||||||
|
......
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
201
专栏/Java并发编程实战/36生产者-消费者模式:用流水线思想提高效率.md
Normal file
201
专栏/Java并发编程实战/36生产者-消费者模式:用流水线思想提高效率.md
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
36 生产者-消费者模式:用流水线思想提高效率
|
||||||
|
前面我们在《34 | Worker Thread模式:如何避免重复创建线程?》中讲到,Worker Thread模式类比的是工厂里车间工人的工作模式。但其实在现实世界,工厂里还有一种流水线的工作模式,类比到编程领域,就是生产者-消费者模式。
|
||||||
|
|
||||||
|
生产者-消费者模式在编程领域的应用也非常广泛,前面我们曾经提到,Java线程池本质上就是用生产者-消费者模式实现的,所以每当使用线程池的时候,其实就是在应用生产者-消费者模式。
|
||||||
|
|
||||||
|
当然,除了在线程池中的应用,为了提升性能,并发编程领域很多地方也都用到了生产者-消费者模式,例如Log4j2中异步Appender内部也用到了生产者-消费者模式。所以今天我们就来深入地聊聊生产者-消费者模式,看看它具体有哪些优点,以及如何提升系统的性能。
|
||||||
|
|
||||||
|
生产者-消费者模式的优点
|
||||||
|
|
||||||
|
生产者-消费者模式的核心是一个任务队列,生产者线程生产任务,并将任务添加到任务队列中,而消费者线程从任务队列中获取任务并执行。下面是生产者-消费者模式的一个示意图,你可以结合它来理解。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
生产者-消费者模式示意图
|
||||||
|
|
||||||
|
从架构设计的角度来看,生产者-消费者模式有一个很重要的优点,就是解耦。解耦对于大型系统的设计非常重要,而解耦的一个关键就是组件之间的依赖关系和通信方式必须受限。在生产者-消费者模式中,生产者和消费者没有任何依赖关系,它们彼此之间的通信只能通过任务队列,所以生产者-消费者模式是一个不错的解耦方案。
|
||||||
|
|
||||||
|
除了架构设计上的优点之外,生产者-消费者模式还有一个重要的优点就是支持异步,并且能够平衡生产者和消费者的速度差异。在生产者-消费者模式中,生产者线程只需要将任务添加到任务队列而无需等待任务被消费者线程执行完,也就是说任务的生产和消费是异步的,这是与传统的方法之间调用的本质区别,传统的方法之间调用是同步的。
|
||||||
|
|
||||||
|
你或许会有这样的疑问,异步化处理最简单的方式就是创建一个新的线程去处理,那中间增加一个“任务队列”究竟有什么用呢?我觉得主要还是用于平衡生产者和消费者的速度差异。我们假设生产者的速率很慢,而消费者的速率很高,比如是1:3,如果生产者有3个线程,采用创建新的线程的方式,那么会创建3个子线程,而采用生产者-消费者模式,消费线程只需要1个就可以了。Java语言里,Java线程和操作系统线程是一一对应的,线程创建得太多,会增加上下文切换的成本,所以Java线程不是越多越好,适量即可。而生产者-消费者模式恰好能支持你用适量的线程。
|
||||||
|
|
||||||
|
支持批量执行以提升性能
|
||||||
|
|
||||||
|
前面我们在《33 | Thread-Per-Message模式:最简单实用的分工方法》中讲过轻量级的线程,如果使用轻量级线程,就没有必要平衡生产者和消费者的速度差异了,因为轻量级线程本身就是廉价的,那是否意味着生产者-消费者模式在性能优化方面就无用武之地了呢?当然不是,有一类并发场景应用生产者-消费者模式就有奇效,那就是批量执行任务。
|
||||||
|
|
||||||
|
例如,我们要在数据库里INSERT 1000条数据,有两种方案:第一种方案是用1000个线程并发执行,每个线程INSERT一条数据;第二种方案是用1个线程,执行一个批量的SQL,一次性把1000条数据INSERT进去。这两种方案,显然是第二种方案效率更高,其实这样的应用场景就是我们上面提到的批量执行场景。
|
||||||
|
|
||||||
|
在《35 | 两阶段终止模式:如何优雅地终止线程?》文章中,我们提到一个监控系统动态采集的案例,其实最终回传的监控数据还是要存入数据库的(如下图)。但被监控系统往往有很多,如果每一条回传数据都直接INSERT到数据库,那么这个方案就是上面提到的第一种方案:每个线程INSERT一条数据。很显然,更好的方案是批量执行SQL,那如何实现呢?这就要用到生产者-消费者模式了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
动态采集功能示意图
|
||||||
|
|
||||||
|
利用生产者-消费者模式实现批量执行SQL非常简单:将原来直接INSERT数据到数据库的线程作为生产者线程,生产者线程只需将数据添加到任务队列,然后消费者线程负责将任务从任务队列中批量取出并批量执行。
|
||||||
|
|
||||||
|
在下面的示例代码中,我们创建了5个消费者线程负责批量执行SQL,这5个消费者线程以 while(true){} 循环方式批量地获取任务并批量地执行。需要注意的是,从任务队列中获取批量任务的方法pollTasks()中,首先是以阻塞方式获取任务队列中的一条任务,而后则是以非阻塞的方式获取任务;之所以首先采用阻塞方式,是因为如果任务队列中没有任务,这样的方式能够避免无谓的循环。
|
||||||
|
|
||||||
|
//任务队列
|
||||||
|
BlockingQueue<Task> bq=new
|
||||||
|
LinkedBlockingQueue<>(2000);
|
||||||
|
//启动5个消费者线程
|
||||||
|
//执行批量任务
|
||||||
|
void start() {
|
||||||
|
ExecutorService es=executors
|
||||||
|
.newFixedThreadPool(5);
|
||||||
|
for (int i=0; i<5; i++) {
|
||||||
|
es.execute(()->{
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
//获取批量任务
|
||||||
|
List<Task> ts=pollTasks();
|
||||||
|
//执行批量任务
|
||||||
|
execTasks(ts);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//从任务队列中获取批量任务
|
||||||
|
List<Task> pollTasks()
|
||||||
|
throws InterruptedException{
|
||||||
|
List<Task> ts=new LinkedList<>();
|
||||||
|
//阻塞式获取一条任务
|
||||||
|
Task t = bq.take();
|
||||||
|
while (t != null) {
|
||||||
|
ts.add(t);
|
||||||
|
//非阻塞式获取一条任务
|
||||||
|
t = bq.poll();
|
||||||
|
}
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
//批量执行任务
|
||||||
|
execTasks(List<Task> ts) {
|
||||||
|
//省略具体代码无数
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
支持分阶段提交以提升性能
|
||||||
|
|
||||||
|
利用生产者-消费者模式还可以轻松地支持一种分阶段提交的应用场景。我们知道写文件如果同步刷盘性能会很慢,所以对于不是很重要的数据,我们往往采用异步刷盘的方式。我曾经参与过一个项目,其中的日志组件是自己实现的,采用的就是异步刷盘方式,刷盘的时机是:
|
||||||
|
|
||||||
|
|
||||||
|
ERROR级别的日志需要立即刷盘;
|
||||||
|
数据积累到500条需要立即刷盘;
|
||||||
|
存在未刷盘数据,且5秒钟内未曾刷盘,需要立即刷盘。
|
||||||
|
|
||||||
|
|
||||||
|
这个日志组件的异步刷盘操作本质上其实就是一种分阶段提交。下面我们具体看看用生产者-消费者模式如何实现。在下面的示例代码中,可以通过调用 info()和error() 方法写入日志,这两个方法都是创建了一个日志任务LogMsg,并添加到阻塞队列中,调用 info()和error() 方法的线程是生产者;而真正将日志写入文件的是消费者线程,在Logger这个类中,我们只创建了1个消费者线程,在这个消费者线程中,会根据刷盘规则执行刷盘操作,逻辑很简单,这里就不赘述了。
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
//任务队列
|
||||||
|
final BlockingQueue<LogMsg> bq
|
||||||
|
= new BlockingQueue<>();
|
||||||
|
//flush批量
|
||||||
|
static final int batchSize=500;
|
||||||
|
//只需要一个线程写日志
|
||||||
|
ExecutorService es =
|
||||||
|
Executors.newFixedThreadPool(1);
|
||||||
|
//启动写日志线程
|
||||||
|
void start(){
|
||||||
|
File file=File.createTempFile(
|
||||||
|
"foo", ".log");
|
||||||
|
final FileWriter writer=
|
||||||
|
new FileWriter(file);
|
||||||
|
this.es.execute(()->{
|
||||||
|
try {
|
||||||
|
//未刷盘日志数量
|
||||||
|
int curIdx = 0;
|
||||||
|
long preFT=System.currentTimeMillis();
|
||||||
|
while (true) {
|
||||||
|
LogMsg log = bq.poll(
|
||||||
|
5, TimeUnit.SECONDS);
|
||||||
|
//写日志
|
||||||
|
if (log != null) {
|
||||||
|
writer.write(log.toString());
|
||||||
|
++curIdx;
|
||||||
|
}
|
||||||
|
//如果不存在未刷盘数据,则无需刷盘
|
||||||
|
if (curIdx <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
//根据规则刷盘
|
||||||
|
if (log!=null && log.level==LEVEL.ERROR ||
|
||||||
|
curIdx == batchSize ||
|
||||||
|
System.currentTimeMillis()-preFT>5000){
|
||||||
|
writer.flush();
|
||||||
|
curIdx = 0;
|
||||||
|
preFT=System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(Exception e){
|
||||||
|
e.printStackTrace();
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
writer.flush();
|
||||||
|
writer.close();
|
||||||
|
}catch(IOException e){
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//写INFO级别日志
|
||||||
|
void info(String msg) {
|
||||||
|
bq.put(new LogMsg(
|
||||||
|
LEVEL.INFO, msg));
|
||||||
|
}
|
||||||
|
//写ERROR级别日志
|
||||||
|
void error(String msg) {
|
||||||
|
bq.put(new LogMsg(
|
||||||
|
LEVEL.ERROR, msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//日志级别
|
||||||
|
enum LEVEL {
|
||||||
|
INFO, ERROR
|
||||||
|
}
|
||||||
|
class LogMsg {
|
||||||
|
LEVEL level;
|
||||||
|
String msg;
|
||||||
|
//省略构造函数实现
|
||||||
|
LogMsg(LEVEL lvl, String msg){}
|
||||||
|
//省略toString()实现
|
||||||
|
String toString(){}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Java语言提供的线程池本身就是一种生产者-消费者模式的实现,但是线程池中的线程每次只能从任务队列中消费一个任务来执行,对于大部分并发场景这种策略都没有问题。但是有些场景还是需要自己来实现,例如需要批量执行以及分阶段提交的场景。
|
||||||
|
|
||||||
|
生产者-消费者模式在分布式计算中的应用也非常广泛。在分布式场景下,你可以借助分布式消息队列(MQ)来实现生产者-消费者模式。MQ一般都会支持两种消息模型,一种是点对点模型,一种是发布订阅模型。这两种模型的区别在于,点对点模型里一个消息只会被一个消费者消费,和Java的线程池非常类似(Java线程池的任务也只会被一个线程执行);而发布订阅模型里一个消息会被多个消费者消费,本质上是一种消息的广播,在多线程编程领域,你可以结合观察者模式实现广播功能。
|
||||||
|
|
||||||
|
课后思考
|
||||||
|
|
||||||
|
在日志组件异步刷盘的示例代码中,写日志的线程以 while(true){} 的方式执行,你有哪些办法可以优雅地终止这个线程呢?
|
||||||
|
|
||||||
|
this.writer.execute(()->{
|
||||||
|
try {
|
||||||
|
//未刷盘日志数量
|
||||||
|
int curIdx = 0;
|
||||||
|
long preFT=System.currentTimeMillis();
|
||||||
|
while (true) {
|
||||||
|
......
|
||||||
|
}
|
||||||
|
} catch(Exception e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
165
专栏/Java并发编程实战/37设计模式模块热点问题答疑.md
Normal file
165
专栏/Java并发编程实战/37设计模式模块热点问题答疑.md
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
37 设计模式模块热点问题答疑
|
||||||
|
多线程设计模式是前人解决并发问题的经验总结,当我们试图解决一个并发问题时,首选方案往往是使用匹配的设计模式,这样能避免走弯路。同时,由于大家都熟悉设计模式,所以使用设计模式还能提升方案和代码的可理解性。
|
||||||
|
|
||||||
|
在这个模块,我们总共介绍了9种常见的多线程设计模式。下面我们就对这9种设计模式做个分类和总结,同时也对前面各章的课后思考题做个答疑。
|
||||||
|
|
||||||
|
避免共享的设计模式
|
||||||
|
|
||||||
|
Immutability模式、Copy-on-Write模式和线程本地存储模式本质上都是为了避免共享,只是实现手段不同而已。这3种设计模式的实现都很简单,但是实现过程中有些细节还是需要格外注意的。例如,使用Immutability模式需要注意对象属性的不可变性,使用Copy-on-Write模式需要注意性能问题,使用线程本地存储模式需要注意异步执行问题。所以,每篇文章最后我设置的课后思考题的目的就是提醒你注意这些细节。
|
||||||
|
|
||||||
|
《28 | Immutability模式:如何利用不变性解决并发问题?》的课后思考题是讨论Account这个类是不是具备不可变性。这个类初看上去属于不可变对象的中规中矩实现,而实质上这个实现是有问题的,原因在于StringBuffer不同于String,StringBuffer不具备不可变性,通过getUser()方法获取user之后,是可以修改user的。一个简单的解决方案是让getUser()方法返回String对象。
|
||||||
|
|
||||||
|
public final class Account{
|
||||||
|
private final
|
||||||
|
StringBuffer user;
|
||||||
|
public Account(String user){
|
||||||
|
this.user =
|
||||||
|
new StringBuffer(user);
|
||||||
|
}
|
||||||
|
//返回的StringBuffer并不具备不可变性
|
||||||
|
public StringBuffer getUser(){
|
||||||
|
return this.user;
|
||||||
|
}
|
||||||
|
public String toString(){
|
||||||
|
return "user"+user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
《29 | Copy-on-Write模式:不是延时策略的COW》的课后思考题是讨论Java SDK中为什么没有提供 CopyOnWriteLinkedList。这是一个开放性的问题,没有标准答案,但是性能问题一定是其中一个很重要的原因,毕竟完整地复制LinkedList性能开销太大了。
|
||||||
|
|
||||||
|
《30 | 线程本地存储模式:没有共享,就没有伤害》的课后思考题是在异步场景中,是否可以使用 Spring 的事务管理器。答案显然是不能的,Spring 使用 ThreadLocal 来传递事务信息,因此这个事务信息是不能跨线程共享的。实际工作中有很多类库都是用 ThreadLocal 传递上下文信息的,这种场景下如果有异步操作,一定要注意上下文信息是不能跨线程共享的。
|
||||||
|
|
||||||
|
多线程版本IF的设计模式
|
||||||
|
|
||||||
|
Guarded Suspension模式和Balking模式都可以简单地理解为“多线程版本的if”,但它们的区别在于前者会等待if条件变为真,而后者则不需要等待。
|
||||||
|
|
||||||
|
Guarded Suspension模式的经典实现是使用管程,很多初学者会简单地用线程sleep的方式实现,比如《31 | Guarded Suspension模式:等待唤醒机制的规范实现》的思考题就是用线程sleep方式实现的。但不推荐你使用这种方式,最重要的原因是性能,如果sleep的时间太长,会影响响应时间;sleep的时间太短,会导致线程频繁地被唤醒,消耗系统资源。
|
||||||
|
|
||||||
|
同时,示例代码的实现也有问题:由于obj不是volatile变量,所以即便obj被设置了正确的值,执行 while(!p.test(obj)) 的线程也有可能看不到,从而导致更长时间的sleep。
|
||||||
|
|
||||||
|
//获取受保护对象
|
||||||
|
T get(Predicate<T> p) {
|
||||||
|
try {
|
||||||
|
//obj的可见性无法保证
|
||||||
|
while(!p.test(obj)){
|
||||||
|
TimeUnit.SECONDS
|
||||||
|
.sleep(timeout);
|
||||||
|
}
|
||||||
|
}catch(InterruptedException e){
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
//返回非空的受保护对象
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
//事件通知方法
|
||||||
|
void onChanged(T obj) {
|
||||||
|
this.obj = obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
实现Balking模式最容易忽视的就是竞态条件问题。比如,《32 | Balking模式:再谈线程安全的单例模式》的思考题就存在竞态条件问题。因此,在多线程场景中使用if语句时,一定要多问自己一遍:是否存在竞态条件。
|
||||||
|
|
||||||
|
class Test{
|
||||||
|
volatile boolean inited = false;
|
||||||
|
int count = 0;
|
||||||
|
void init(){
|
||||||
|
//存在竞态条件
|
||||||
|
if(inited){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
//有可能多个线程执行到这里
|
||||||
|
inited = true;
|
||||||
|
//计算count的值
|
||||||
|
count = calc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
三种最简单的分工模式
|
||||||
|
|
||||||
|
Thread-Per-Message模式、Worker Thread模式和生产者-消费者模式是三种最简单实用的多线程分工方法。虽说简单,但也还是有许多细节需要你多加小心和注意。
|
||||||
|
|
||||||
|
Thread-Per-Message模式在实现的时候需要注意是否存在线程的频繁创建、销毁以及是否可能导致OOM。在《33 | Thread-Per-Message模式:最简单实用的分工方法》文章中,最后的思考题就是关于如何快速解决OOM问题的。在高并发场景中,最简单的办法其实是限流。当然,限流方案也并不局限于解决Thread-Per-Message模式中的OOM问题。
|
||||||
|
|
||||||
|
Worker Thread模式的实现,需要注意潜在的线程死锁问题。《34 | Worker Thread模式:如何避免重复创建线程?》思考题中的示例代码就存在线程死锁。有名叫vector的同学关于这道思考题的留言,我觉得描述得很贴切和形象:“工厂里只有一个工人,他的工作就是同步地等待工厂里其他人给他提供东西,然而并没有其他人,他将等到天荒地老,海枯石烂!”因此,共享线程池虽然能够提供线程池的使用效率,但一定要保证一个前提,那就是:任务之间没有依赖关系。
|
||||||
|
|
||||||
|
ExecutorService pool = Executors
|
||||||
|
.newSingleThreadExecutor();
|
||||||
|
//提交主任务
|
||||||
|
pool.submit(() -> {
|
||||||
|
try {
|
||||||
|
//提交子任务并等待其完成,
|
||||||
|
//会导致线程死锁
|
||||||
|
String qq=pool.submit(()->"QQ").get();
|
||||||
|
System.out.println(qq);
|
||||||
|
} catch (Exception e) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
Java线程池本身就是一种生产者-消费者模式的实现,所以大部分场景你都不需要自己实现,直接使用Java的线程池就可以了。但若能自己灵活地实现生产者-消费者模式会更好,比如可以实现批量执行和分阶段提交,不过这过程中还需要注意如何优雅地终止线程,《36 | 生产者-消费者模式:用流水线思想提高效率》的思考题就是关于此的。
|
||||||
|
|
||||||
|
如何优雅地终止线程?我们在《35 | 两阶段终止模式:如何优雅地终止线程?》有过详细介绍,两阶段终止模式是一种通用的解决方案。但其实终止生产者-消费者服务还有一种更简单的方案,叫做“毒丸”对象。《Java并发编程实战》第7章的7.2.3节对“毒丸”对象有过详细的介绍。简单来讲,“毒丸”对象是生产者生产的一条特殊任务,然后当消费者线程读到“毒丸”对象时,会立即终止自身的执行。
|
||||||
|
|
||||||
|
下面是用“毒丸”对象终止写日志线程的具体实现,整体的实现过程还是很简单的:类Logger中声明了一个“毒丸”对象poisonPill ,当消费者线程从阻塞队列bq中取出一条LogMsg后,先判断是否是“毒丸”对象,如果是,则break while循环,从而终止自己的执行。
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
//用于终止日志执行的“毒丸”
|
||||||
|
final LogMsg poisonPill =
|
||||||
|
new LogMsg(LEVEL.ERROR, "");
|
||||||
|
//任务队列
|
||||||
|
final BlockingQueue<LogMsg> bq
|
||||||
|
= new BlockingQueue<>();
|
||||||
|
//只需要一个线程写日志
|
||||||
|
ExecutorService es =
|
||||||
|
Executors.newFixedThreadPool(1);
|
||||||
|
//启动写日志线程
|
||||||
|
void start(){
|
||||||
|
File file=File.createTempFile(
|
||||||
|
"foo", ".log");
|
||||||
|
final FileWriter writer=
|
||||||
|
new FileWriter(file);
|
||||||
|
this.es.execute(()->{
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
LogMsg log = bq.poll(
|
||||||
|
5, TimeUnit.SECONDS);
|
||||||
|
//如果是“毒丸”,终止执行
|
||||||
|
if(poisonPill.equals(logMsg)){
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
//省略执行逻辑
|
||||||
|
}
|
||||||
|
} catch(Exception e){
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
writer.flush();
|
||||||
|
writer.close();
|
||||||
|
}catch(IOException e){}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//终止写日志线程
|
||||||
|
public void stop() {
|
||||||
|
//将“毒丸”对象加入阻塞队列
|
||||||
|
bq.add(poisonPill);
|
||||||
|
es.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
到今天为止,“并发设计模式”模块就告一段落了,多线程的设计模式当然不止我们提到的这9种,不过这里提到的这9种设计模式一定是最简单实用的。如果感兴趣,你也可以结合《图解Java多线程设计模式》这本书来深入学习这个模块,这是一本不错的并发编程入门书籍,虽然重点是讲解设计模式,但是也详细讲解了设计模式中涉及到的方方面面的基础知识,而且深入浅出,非常推荐入门的同学认真学习一下。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
215
专栏/Java并发编程实战/38案例分析(一):高性能限流器GuavaRateLimiter.md
Normal file
215
专栏/Java并发编程实战/38案例分析(一):高性能限流器GuavaRateLimiter.md
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
38 案例分析(一):高性能限流器Guava RateLimiter
|
||||||
|
从今天开始,我们就进入案例分析模块了。 这个模块我们将分析四个经典的开源框架,看看它们是如何处理并发问题的,通过这四个案例的学习,相信你会对如何解决并发问题有个更深入的认识。
|
||||||
|
|
||||||
|
首先我们来看看Guava RateLimiter是如何解决高并发场景下的限流问题的。Guava是Google开源的Java类库,提供了一个工具类RateLimiter。我们先来看看RateLimiter的使用,让你对限流有个感官的印象。假设我们有一个线程池,它每秒只能处理两个任务,如果提交的任务过快,可能导致系统不稳定,这个时候就需要用到限流。
|
||||||
|
|
||||||
|
在下面的示例代码中,我们创建了一个流速为2个请求/秒的限流器,这里的流速该怎么理解呢?直观地看,2个请求/秒指的是每秒最多允许2个请求通过限流器,其实在Guava中,流速还有更深一层的意思:是一种匀速的概念,2个请求/秒等价于1个请求/500毫秒。
|
||||||
|
|
||||||
|
在向线程池提交任务之前,调用 acquire() 方法就能起到限流的作用。通过示例代码的执行结果,任务提交到线程池的时间间隔基本上稳定在500毫秒。
|
||||||
|
|
||||||
|
//限流器流速:2个请求/秒
|
||||||
|
RateLimiter limiter =
|
||||||
|
RateLimiter.create(2.0);
|
||||||
|
//执行任务的线程池
|
||||||
|
ExecutorService es = Executors
|
||||||
|
.newFixedThreadPool(1);
|
||||||
|
//记录上一次执行时间
|
||||||
|
prev = System.nanoTime();
|
||||||
|
//测试执行20次
|
||||||
|
for (int i=0; i<20; i++){
|
||||||
|
//限流器限流
|
||||||
|
limiter.acquire();
|
||||||
|
//提交任务异步执行
|
||||||
|
es.execute(()->{
|
||||||
|
long cur=System.nanoTime();
|
||||||
|
//打印时间间隔:毫秒
|
||||||
|
System.out.println(
|
||||||
|
(cur-prev)/1000_000);
|
||||||
|
prev = cur;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
输出结果:
|
||||||
|
...
|
||||||
|
500
|
||||||
|
499
|
||||||
|
499
|
||||||
|
500
|
||||||
|
499
|
||||||
|
|
||||||
|
|
||||||
|
经典限流算法:令牌桶算法
|
||||||
|
|
||||||
|
Guava的限流器使用上还是很简单的,那它是如何实现的呢?Guava采用的是令牌桶算法,其核心是要想通过限流器,必须拿到令牌。也就是说,只要我们能够限制发放令牌的速率,那么就能控制流速了。令牌桶算法的详细描述如下:
|
||||||
|
|
||||||
|
|
||||||
|
令牌以固定的速率添加到令牌桶中,假设限流的速率是 r/秒,则令牌每 1/r 秒会添加一个;
|
||||||
|
假设令牌桶的容量是 b ,如果令牌桶已满,则新的令牌会被丢弃;
|
||||||
|
请求能够通过限流器的前提是令牌桶中有令牌。
|
||||||
|
|
||||||
|
|
||||||
|
这个算法中,限流的速率 r 还是比较容易理解的,但令牌桶的容量 b 该怎么理解呢?b 其实是burst的简写,意义是限流器允许的最大突发流量。比如b=10,而且令牌桶中的令牌已满,此时限流器允许10个请求同时通过限流器,当然只是突发流量而已,这10个请求会带走10个令牌,所以后续的流量只能按照速率 r 通过限流器。
|
||||||
|
|
||||||
|
令牌桶这个算法,如何用Java实现呢?很可能你的直觉会告诉你生产者-消费者模式:一个生产者线程定时向阻塞队列中添加令牌,而试图通过限流器的线程则作为消费者线程,只有从阻塞队列中获取到令牌,才允许通过限流器。
|
||||||
|
|
||||||
|
这个算法看上去非常完美,而且实现起来非常简单,如果并发量不大,这个实现并没有什么问题。可实际情况却是使用限流的场景大部分都是高并发场景,而且系统压力已经临近极限了,此时这个实现就有问题了。问题就出在定时器上,在高并发场景下,当系统压力已经临近极限的时候,定时器的精度误差会非常大,同时定时器本身会创建调度线程,也会对系统的性能产生影响。
|
||||||
|
|
||||||
|
那还有什么好的实现方式呢?当然有,Guava的实现就没有使用定时器,下面我们就来看看它是如何实现的。
|
||||||
|
|
||||||
|
Guava如何实现令牌桶算法
|
||||||
|
|
||||||
|
Guava实现令牌桶算法,用了一个很简单的办法,其关键是记录并动态计算下一令牌发放的时间。下面我们以一个最简单的场景来介绍该算法的执行过程。假设令牌桶的容量为 b=1,限流速率 r = 1个请求/秒,如下图所示,如果当前令牌桶中没有令牌,下一个令牌的发放时间是在第3秒,而在第2秒的时候有一个线程T1请求令牌,此时该如何处理呢?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
线程T1请求令牌示意图
|
||||||
|
|
||||||
|
对于这个请求令牌的线程而言,很显然需要等待1秒,因为1秒以后(第3秒)它就能拿到令牌了。此时需要注意的是,下一个令牌发放的时间也要增加1秒,为什么呢?因为第3秒发放的令牌已经被线程T1预占了。处理之后如下图所示。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
线程T1请求结束示意图
|
||||||
|
|
||||||
|
假设T1在预占了第3秒的令牌之后,马上又有一个线程T2请求令牌,如下图所示。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
线程T2请求令牌示意图
|
||||||
|
|
||||||
|
很显然,由于下一个令牌产生的时间是第4秒,所以线程T2要等待两秒的时间,才能获取到令牌,同时由于T2预占了第4秒的令牌,所以下一令牌产生时间还要增加1秒,完全处理之后,如下图所示。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
线程T2请求结束示意图
|
||||||
|
|
||||||
|
上面线程T1、T2都是在下一令牌产生时间之前请求令牌,如果线程在下一令牌产生时间之后请求令牌会如何呢?假设在线程T1请求令牌之后的5秒,也就是第7秒,线程T3请求令牌,如下图所示。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
线程T3请求令牌示意图
|
||||||
|
|
||||||
|
由于在第5秒已经产生了一个令牌,所以此时线程T3可以直接拿到令牌,而无需等待。在第7秒,实际上限流器能够产生3个令牌,第5、6、7秒各产生一个令牌。由于我们假设令牌桶的容量是1,所以第6、7秒产生的令牌就丢弃了,其实等价地你也可以认为是保留的第7秒的令牌,丢弃的第5、6秒的令牌,也就是说第7秒的令牌被线程T3占有了,于是下一令牌的的产生时间应该是第8秒,如下图所示。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
线程T3请求结束示意图
|
||||||
|
|
||||||
|
通过上面简要地分析,你会发现,我们只需要记录一个下一令牌产生的时间,并动态更新它,就能够轻松完成限流功能。我们可以将上面的这个算法代码化,示例代码如下所示,依然假设令牌桶的容量是1。关键是reserve()方法,这个方法会为请求令牌的线程预分配令牌,同时返回该线程能够获取令牌的时间。其实现逻辑就是上面提到的:如果线程请求令牌的时间在下一令牌产生时间之后,那么该线程立刻就能够获取令牌;反之,如果请求时间在下一令牌产生时间之前,那么该线程是在下一令牌产生的时间获取令牌。由于此时下一令牌已经被该线程预占,所以下一令牌产生的时间需要加上1秒。
|
||||||
|
|
||||||
|
class SimpleLimiter {
|
||||||
|
//下一令牌产生时间
|
||||||
|
long next = System.nanoTime();
|
||||||
|
//发放令牌间隔:纳秒
|
||||||
|
long interval = 1000_000_000;
|
||||||
|
//预占令牌,返回能够获取令牌的时间
|
||||||
|
synchronized long reserve(long now){
|
||||||
|
//请求时间在下一令牌产生时间之后
|
||||||
|
//重新计算下一令牌产生时间
|
||||||
|
if (now > next){
|
||||||
|
//将下一令牌产生时间重置为当前时间
|
||||||
|
next = now;
|
||||||
|
}
|
||||||
|
//能够获取令牌的时间
|
||||||
|
long at=next;
|
||||||
|
//设置下一令牌产生时间
|
||||||
|
next += interval;
|
||||||
|
//返回线程需要等待的时间
|
||||||
|
return Math.max(at, 0L);
|
||||||
|
}
|
||||||
|
//申请令牌
|
||||||
|
void acquire() {
|
||||||
|
//申请令牌时的时间
|
||||||
|
long now = System.nanoTime();
|
||||||
|
//预占令牌
|
||||||
|
long at=reserve(now);
|
||||||
|
long waitTime=max(at-now, 0);
|
||||||
|
//按照条件等待
|
||||||
|
if(waitTime > 0) {
|
||||||
|
try {
|
||||||
|
TimeUnit.NANOSECONDS
|
||||||
|
.sleep(waitTime);
|
||||||
|
}catch(InterruptedException e){
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
如果令牌桶的容量大于1,又该如何处理呢?按照令牌桶算法,令牌要首先从令牌桶中出,所以我们需要按需计算令牌桶中的数量,当有线程请求令牌时,先从令牌桶中出。具体的代码实现如下所示。我们增加了一个resync()方法,在这个方法中,如果线程请求令牌的时间在下一令牌产生时间之后,会重新计算令牌桶中的令牌数,新产生的令牌的计算公式是:(now-next)/interval,你可对照上面的示意图来理解。reserve()方法中,则增加了先从令牌桶中出令牌的逻辑,不过需要注意的是,如果令牌是从令牌桶中出的,那么next就无需增加一个 interval 了。
|
||||||
|
|
||||||
|
class SimpleLimiter {
|
||||||
|
//当前令牌桶中的令牌数量
|
||||||
|
long storedPermits = 0;
|
||||||
|
//令牌桶的容量
|
||||||
|
long maxPermits = 3;
|
||||||
|
//下一令牌产生时间
|
||||||
|
long next = System.nanoTime();
|
||||||
|
//发放令牌间隔:纳秒
|
||||||
|
long interval = 1000_000_000;
|
||||||
|
|
||||||
|
//请求时间在下一令牌产生时间之后,则
|
||||||
|
// 1.重新计算令牌桶中的令牌数
|
||||||
|
// 2.将下一个令牌发放时间重置为当前时间
|
||||||
|
void resync(long now) {
|
||||||
|
if (now > next) {
|
||||||
|
//新产生的令牌数
|
||||||
|
long newPermits=(now-next)/interval;
|
||||||
|
//新令牌增加到令牌桶
|
||||||
|
storedPermits=min(maxPermits,
|
||||||
|
storedPermits + newPermits);
|
||||||
|
//将下一个令牌发放时间重置为当前时间
|
||||||
|
next = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//预占令牌,返回能够获取令牌的时间
|
||||||
|
synchronized long reserve(long now){
|
||||||
|
resync(now);
|
||||||
|
//能够获取令牌的时间
|
||||||
|
long at = next;
|
||||||
|
//令牌桶中能提供的令牌
|
||||||
|
long fb=min(1, storedPermits);
|
||||||
|
//令牌净需求:首先减掉令牌桶中的令牌
|
||||||
|
long nr = 1 - fb;
|
||||||
|
//重新计算下一令牌产生时间
|
||||||
|
next = next + nr*interval;
|
||||||
|
//重新计算令牌桶中的令牌
|
||||||
|
this.storedPermits -= fb;
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
//申请令牌
|
||||||
|
void acquire() {
|
||||||
|
//申请令牌时的时间
|
||||||
|
long now = System.nanoTime();
|
||||||
|
//预占令牌
|
||||||
|
long at=reserve(now);
|
||||||
|
long waitTime=max(at-now, 0);
|
||||||
|
//按照条件等待
|
||||||
|
if(waitTime > 0) {
|
||||||
|
try {
|
||||||
|
TimeUnit.NANOSECONDS
|
||||||
|
.sleep(waitTime);
|
||||||
|
}catch(InterruptedException e){
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
经典的限流算法有两个,一个是令牌桶算法(Token Bucket),另一个是漏桶算法(Leaky Bucket)。令牌桶算法是定时向令牌桶发送令牌,请求能够从令牌桶中拿到令牌,然后才能通过限流器;而漏桶算法里,请求就像水一样注入漏桶,漏桶会按照一定的速率自动将水漏掉,只有漏桶里还能注入水的时候,请求才能通过限流器。令牌桶算法和漏桶算法很像一个硬币的正反面,所以你可以参考令牌桶算法的实现来实现漏桶算法。
|
||||||
|
|
||||||
|
上面我们介绍了Guava是如何实现令牌桶算法的,我们的示例代码是对Guava RateLimiter的简化,Guava RateLimiter扩展了标准的令牌桶算法,比如还能支持预热功能。对于按需加载的缓存来说,预热后缓存能支持5万TPS的并发,但是在预热前5万TPS的并发直接就把缓存击垮了,所以如果需要给该缓存限流,限流器也需要支持预热功能,在初始阶段,限制的流速 r 很小,但是动态增长的。预热功能的实现非常复杂,Guava构建了一个积分函数来解决这个问题,如果你感兴趣,可以继续深入研究。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
145
专栏/Java并发编程实战/39案例分析(二):高性能网络应用框架Netty.md
Normal file
145
专栏/Java并发编程实战/39案例分析(二):高性能网络应用框架Netty.md
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
39 案例分析(二):高性能网络应用框架Netty
|
||||||
|
Netty是一个高性能网络应用框架,应用非常普遍,目前在Java领域里,Netty基本上成为网络程序的标配了。Netty框架功能丰富,也非常复杂,今天我们主要分析Netty框架中的线程模型,而线程模型直接影响着网络程序的性能。
|
||||||
|
|
||||||
|
在介绍Netty的线程模型之前,我们首先需要把问题搞清楚,了解网络编程性能的瓶颈在哪里,然后再看Netty的线程模型是如何解决这个问题的。
|
||||||
|
|
||||||
|
网络编程性能的瓶颈
|
||||||
|
|
||||||
|
在《33 | Thread-Per-Message模式:最简单实用的分工方法》中,我们写过一个简单的网络程序echo,采用的是阻塞式I/O(BIO)。BIO模型里,所有read()操作和write()操作都会阻塞当前线程的,如果客户端已经和服务端建立了一个连接,而迟迟不发送数据,那么服务端的read()操作会一直阻塞,所以使用BIO模型,一般都会为每个socket分配一个独立的线程,这样就不会因为线程阻塞在一个socket上而影响对其他socket的读写。BIO的线程模型如下图所示,每一个socket都对应一个独立的线程;为了避免频繁创建、消耗线程,可以采用线程池,但是socket和线程之间的对应关系并不会变化。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
BIO的线程模型
|
||||||
|
|
||||||
|
BIO这种线程模型适用于socket连接不是很多的场景;但是现在的互联网场景,往往需要服务器能够支撑十万甚至百万连接,而创建十万甚至上百万个线程显然并不现实,所以BIO线程模型无法解决百万连接的问题。如果仔细观察,你会发现互联网场景中,虽然连接多,但是每个连接上的请求并不频繁,所以线程大部分时间都在等待I/O就绪。也就是说线程大部分时间都阻塞在那里,这完全是浪费,如果我们能够解决这个问题,那就不需要这么多线程了。
|
||||||
|
|
||||||
|
顺着这个思路,我们可以将线程模型优化为下图这个样子,可以用一个线程来处理多个连接,这样线程的利用率就上来了,同时所需的线程数量也跟着降下来了。这个思路很好,可是使用BIO相关的API是无法实现的,这是为什么呢?因为BIO相关的socket读写操作都是阻塞式的,而一旦调用了阻塞式API,在I/O就绪前,调用线程会一直阻塞,也就无法处理其他的socket连接了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
理想的线程模型图
|
||||||
|
|
||||||
|
好在Java里还提供了非阻塞式(NIO)API,利用非阻塞式API就能够实现一个线程处理多个连接了。那具体如何实现呢?现在普遍都是采用Reactor模式,包括Netty的实现。所以,要想理解Netty的实现,接下来我们就需要先了解一下Reactor模式。
|
||||||
|
|
||||||
|
Reactor模式
|
||||||
|
|
||||||
|
下面是Reactor模式的类结构图,其中Handle指的是I/O句柄,在Java网络编程里,它本质上就是一个网络连接。Event Handler很容易理解,就是一个事件处理器,其中handle_event()方法处理I/O事件,也就是每个Event Handler处理一个I/O Handle;get_handle()方法可以返回这个I/O的Handle。Synchronous Event Demultiplexer可以理解为操作系统提供的I/O多路复用API,例如POSIX标准里的select()以及Linux里面的epoll()。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Reactor模式类结构图
|
||||||
|
|
||||||
|
Reactor模式的核心自然是Reactor这个类,其中register_handler()和remove_handler()这两个方法可以注册和删除一个事件处理器;handle_events()方式是核心,也是Reactor模式的发动机,这个方法的核心逻辑如下:首先通过同步事件多路选择器提供的select()方法监听网络事件,当有网络事件就绪后,就遍历事件处理器来处理该网络事件。由于网络事件是源源不断的,所以在主程序中启动Reactor模式,需要以 while(true){} 的方式调用handle_events()方法。
|
||||||
|
|
||||||
|
void Reactor::handle_events(){
|
||||||
|
//通过同步事件多路选择器提供的
|
||||||
|
//select()方法监听网络事件
|
||||||
|
select(handlers);
|
||||||
|
//处理网络事件
|
||||||
|
for(h in handlers){
|
||||||
|
h.handle_event();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 在主程序中启动事件循环
|
||||||
|
while (true) {
|
||||||
|
handle_events();
|
||||||
|
|
||||||
|
|
||||||
|
Netty中的线程模型
|
||||||
|
|
||||||
|
Netty的实现虽然参考了Reactor模式,但是并没有完全照搬,Netty中最核心的概念是事件循环(EventLoop),其实也就是Reactor模式中的Reactor,负责监听网络事件并调用事件处理器进行处理。在4.x版本的Netty中,网络连接和EventLoop是稳定的多对1关系,而EventLoop和Java线程是1对1关系,这里的稳定指的是关系一旦确定就不再发生变化。也就是说一个网络连接只会对应唯一的一个EventLoop,而一个EventLoop也只会对应到一个Java线程,所以一个网络连接只会对应到一个Java线程。
|
||||||
|
|
||||||
|
一个网络连接对应到一个Java线程上,有什么好处呢?最大的好处就是对于一个网络连接的事件处理是单线程的,这样就避免了各种并发问题。
|
||||||
|
|
||||||
|
Netty中的线程模型可以参考下图,这个图和前面我们提到的理想的线程模型图非常相似,核心目标都是用一个线程处理多个网络连接。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Netty中的线程模型
|
||||||
|
|
||||||
|
Netty中还有一个核心概念是EventLoopGroup,顾名思义,一个EventLoopGroup由一组EventLoop组成。实际使用中,一般都会创建两个EventLoopGroup,一个称为bossGroup,一个称为workerGroup。为什么会有两个EventLoopGroup呢?
|
||||||
|
|
||||||
|
这个和socket处理网络请求的机制有关,socket处理TCP网络连接请求,是在一个独立的socket中,每当有一个TCP连接成功建立,都会创建一个新的socket,之后对TCP连接的读写都是由新创建处理的socket完成的。也就是说处理TCP连接请求和读写请求是通过两个不同的socket完成的。上面我们在讨论网络请求的时候,为了简化模型,只是讨论了读写请求,而没有讨论连接请求。
|
||||||
|
|
||||||
|
在Netty中,bossGroup就用来处理连接请求的,而workerGroup是用来处理读写请求的。bossGroup处理完连接请求后,会将这个连接提交给workerGroup来处理, workerGroup里面有多个EventLoop,那新的连接会交给哪个EventLoop来处理呢?这就需要一个负载均衡算法,Netty中目前使用的是轮询算法。
|
||||||
|
|
||||||
|
下面我们用Netty重新实现以下echo程序的服务端,近距离感受一下Netty。
|
||||||
|
|
||||||
|
用Netty实现Echo程序服务端
|
||||||
|
|
||||||
|
下面的示例代码基于Netty实现了echo程序服务端:首先创建了一个事件处理器(等同于Reactor模式中的事件处理器),然后创建了bossGroup和workerGroup,再之后创建并初始化了ServerBootstrap,代码还是很简单的,不过有两个地方需要注意一下。
|
||||||
|
|
||||||
|
第一个,如果NettybossGroup只监听一个端口,那bossGroup只需要1个EventLoop就可以了,多了纯属浪费。
|
||||||
|
|
||||||
|
第二个,默认情况下,Netty会创建“2*CPU核数”个EventLoop,由于网络连接与EventLoop有稳定的关系,所以事件处理器在处理网络事件的时候是不能有阻塞操作的,否则很容易导致请求大面积超时。如果实在无法避免使用阻塞操作,那可以通过线程池来异步处理。
|
||||||
|
|
||||||
|
//事件处理器
|
||||||
|
final EchoServerHandler serverHandler
|
||||||
|
= new EchoServerHandler();
|
||||||
|
//boss线程组
|
||||||
|
EventLoopGroup bossGroup
|
||||||
|
= new NioEventLoopGroup(1);
|
||||||
|
//worker线程组
|
||||||
|
EventLoopGroup workerGroup
|
||||||
|
= new NioEventLoopGroup();
|
||||||
|
try {
|
||||||
|
ServerBootstrap b = new ServerBootstrap();
|
||||||
|
b.group(bossGroup, workerGroup)
|
||||||
|
.channel(NioServerSocketChannel.class)
|
||||||
|
.childHandler(new ChannelInitializer<SocketChannel>() {
|
||||||
|
@Override
|
||||||
|
public void initChannel(SocketChannel ch){
|
||||||
|
ch.pipeline().addLast(serverHandler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
//bind服务端端口
|
||||||
|
ChannelFuture f = b.bind(9090).sync();
|
||||||
|
f.channel().closeFuture().sync();
|
||||||
|
} finally {
|
||||||
|
//终止工作线程组
|
||||||
|
workerGroup.shutdownGracefully();
|
||||||
|
//终止boss线程组
|
||||||
|
bossGroup.shutdownGracefully();
|
||||||
|
}
|
||||||
|
|
||||||
|
//socket连接处理器
|
||||||
|
class EchoServerHandler extends
|
||||||
|
ChannelInboundHandlerAdapter {
|
||||||
|
//处理读事件
|
||||||
|
@Override
|
||||||
|
public void channelRead(
|
||||||
|
ChannelHandlerContext ctx, Object msg){
|
||||||
|
ctx.write(msg);
|
||||||
|
}
|
||||||
|
//处理读完成事件
|
||||||
|
@Override
|
||||||
|
public void channelReadComplete(
|
||||||
|
ChannelHandlerContext ctx){
|
||||||
|
ctx.flush();
|
||||||
|
}
|
||||||
|
//处理异常事件
|
||||||
|
@Override
|
||||||
|
public void exceptionCaught(
|
||||||
|
ChannelHandlerContext ctx, Throwable cause) {
|
||||||
|
cause.printStackTrace();
|
||||||
|
ctx.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Netty是一个款优秀的网络编程框架,性能非常好,为了实现高性能的目标,Netty做了很多优化,例如优化了ByteBuffer、支持零拷贝等等,和并发编程相关的就是它的线程模型了。Netty的线程模型设计得很精巧,每个网络连接都关联到了一个线程上,这样做的好处是:对于一个网络连接,读写操作都是单线程执行的,从而避免了并发程序的各种问题。
|
||||||
|
|
||||||
|
你要想深入理解Netty的线程模型,还需要对网络相关知识有一定的理解,关于Java IO的演进过程,你可以参考Scalable IO in Java,至于TCP/IP网络编程的知识你可以参考韩国尹圣雨写的经典教程——《TCP/IP网络编程》。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
83
专栏/Java并发编程实战/3个用户来信打开一个新的并发世界.md
Normal file
83
专栏/Java并发编程实战/3个用户来信打开一个新的并发世界.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
3 个用户来信 打开一个新的并发世界
|
||||||
|
你好,我是王宝令。
|
||||||
|
|
||||||
|
很高兴能再次收到用户的来信,一下子还是 3 封,真是受宠若惊。
|
||||||
|
|
||||||
|
通过大家的来信、留言,我深刻感受到大家学习的欲望和热情,也很感谢你们能跟着我一起,把并发这么难啃的知识点都“嚼碎了”——“吃下去”——“消化掉”,变成自己的东西。
|
||||||
|
|
||||||
|
脚踏实地,才能仰望天空。
|
||||||
|
|
||||||
|
来信一:他说,这是一盏明灯,可以带你少走很多弯路,正确前行,野蛮生长。
|
||||||
|
|
||||||
|
你好,我是笑笑,17届杭师大计算机毕业的学生,现在一个电商互联网公司做 Java开发。
|
||||||
|
|
||||||
|
在没有学习这个专栏之前,我自己也曾读过一些并发编程相关的书,但那时刚毕业,看完后也仅仅是知道了并发的概念、并发产生的原因,以及一些并发工具类的使用,整体处于“了解”阶段,距离“掌握”还很远。所以,看到“极客时间”出并发编程的专栏后,我立马就订阅了。
|
||||||
|
|
||||||
|
第一个感受:宝令老师的讲解思路特别清晰,由简入深。为什么会出现这些技术、这些技术带来的影响点以及如何能更合理地使用这些技术等内容,都阐述得清清楚楚。整个专栏下来,宝令老师带我“游览”并看清了并发编程的全貌。
|
||||||
|
|
||||||
|
第二个感受:清晰简洁,理论和实践并行。每次读完老师的文章后,先前很多模糊的知识点都变得更加地清晰,比如:
|
||||||
|
|
||||||
|
|
||||||
|
可见性是由于在多核时代,每颗CPU都有自己的缓存导致的,具体看《01 | 可见性、原子性和有序性问题:并发编程Bug的源头》;
|
||||||
|
锁要和资源关联起来,一个锁可以锁多个资源,但是一个资源不可以用多个锁,可类比球赛门票的管理,点击温故《03 | 互斥锁(上):解决原子性问题》;
|
||||||
|
Java 线程的生命周期与操作系统线程生命周期的相通点以及区别,可参考《09 | Java线程(上):Java线程的生命周期》;
|
||||||
|
结合例子来带你理解 Happens-Before 规则,具体看《02 | Java内存模型:看Java如何解决可见性和有序性问题》。-
|
||||||
|
……
|
||||||
|
|
||||||
|
|
||||||
|
总之 ,十分感谢宝令老师这几个月的付出。想必很多同学都跟我一样,不能说看了专栏我们并发的能力一下子变得多么多么厉害(这也不现实)。但,它绝对是一盏明灯,给我们指明了方向,让我们在并发的道路上少走很多弯路,正确前行,野蛮生长。
|
||||||
|
|
||||||
|
宝令回信:
|
||||||
|
|
||||||
|
很高兴能够为你答疑解惑,学习最怕的是没问题,只要有问题就一定能找到答案,探索的过程就是提升的过程。也感谢你这几个月的支持和信任!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
来信二:他说,于是,我有了自己的“Java并发编程全景图”。
|
||||||
|
|
||||||
|
你好,我是华应,互联网行业的一名非著名程序员。
|
||||||
|
|
||||||
|
关于并发编程的学习,我也曾多次尝试学习,从不同的切入点或者方法学习过,但都不得要领。 看了宝令老师专栏的试读文章后,我发现每句话都戳中自己学习过程中的痛处,就决定跟着了。
|
||||||
|
|
||||||
|
让我印象比较深刻的是,在第一部分“并发理论基础”的最后,老师专门拿出了一篇文章来为大家答疑,每个问题都非常经典,涉及到CPU、缓存、内存、IO、并发编程相关的操作系统层面的线程、锁、指令等知识点,为我打开了并发新世界。同时,我把难啃的知识放在了自己技能全景图中,时时温故知新。
|
||||||
|
|
||||||
|
总体来说,学习完这个专栏后,我获益良多,不仅是对并发编程有了系统化的理解,也第一次针对并发编程绘制出了自己的全景图(如下图)。生有涯而学无涯,相信这些知识图谱定能给我指明前进方向,点亮我的技术人生。
|
||||||
|
|
||||||
|
真心谢谢宝令老师,希望老师能再出更多的专栏,我们江湖再见!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
宝令回信:
|
||||||
|
|
||||||
|
系统化地学习很重要,这样遇到问题不会迷茫。感谢你分享的全景图,教学相长,我们互相学习!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
来信三:他说,如今每做一个需求,都会对其资源消耗、时间损耗和并发安全有进一步的思考和优化。
|
||||||
|
|
||||||
|
你好,我是小肖,在深圳的一家金融公司负责后端业务开发。
|
||||||
|
|
||||||
|
想起学并发编程,是因为在找工作时,经常会有面试官问我有没有并发经验,这时我才意识到自己在并发方面的不足。但由于整天沉浸在各种业务代码的CRUD中,而且公司用户数量不大,导致自己接触的并发场景少,完全缺少理论+实战经验。
|
||||||
|
|
||||||
|
所以当“极客时间”出了《Java并发编程实战》这门课后,我立马就订阅了,同时也对宝令老师的分享充满了期待。
|
||||||
|
|
||||||
|
事实证明,老师分享的知识深度广度让我叹为观止,干货非常之多,许多知识也很贴近实战。比如,在第二部分“并发工具类”的14篇文章里,我跟着学会了如何用多线程并行操作来优化程序执行时间,以及如何用线程通信来让程序执行得更高效。
|
||||||
|
|
||||||
|
就这样,从头到尾跟下来后,我收获颇丰!
|
||||||
|
|
||||||
|
现在,我也尝试着把我学到的知识点用于项目中,不断优化自己的代码。如今我开始每做一个需求,都会对其资源消耗、时间损耗和并发安全多进行一步思考和优化。这些都为我的项目成功上线起到了重要的保障作用,我的同事也开始夸奖我并发方面的表现突出。这是我最开心和欣慰的地方。
|
||||||
|
|
||||||
|
由此,我想感谢宝令老师,是发自内心且由衷地感谢。感谢老师这几个月的一直陪伴,感谢老师分享的知识让我向着理想更进了一步,感谢老师怀揣着对技术的执着之心激励我初心依旧。
|
||||||
|
|
||||||
|
宝令回信:
|
||||||
|
|
||||||
|
你这么快就能在工作中熟练使用了,这是我最开心和欣慰的地方。学会怎么思考并且在工作中实践,进步一定很快。祝在工作中更上一层楼!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
207
专栏/Java并发编程实战/40案例分析(三):高性能队列Disruptor.md
Normal file
207
专栏/Java并发编程实战/40案例分析(三):高性能队列Disruptor.md
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
40 案例分析(三):高性能队列Disruptor
|
||||||
|
我们在《20 | 并发容器:都有哪些“坑”需要我们填?》介绍过Java SDK提供了2个有界队列:ArrayBlockingQueue 和 LinkedBlockingQueue,它们都是基于ReentrantLock实现的,在高并发场景下,锁的效率并不高,那有没有更好的替代品呢?有,今天我们就介绍一种性能更高的有界队列:Disruptor。
|
||||||
|
|
||||||
|
Disruptor是一款高性能的有界内存队列,目前应用非常广泛,Log4j2、Spring Messaging、HBase、Storm都用到了Disruptor,那Disruptor的性能为什么这么高呢?Disruptor项目团队曾经写过一篇论文,详细解释了其原因,可以总结为如下:
|
||||||
|
|
||||||
|
|
||||||
|
内存分配更加合理,使用RingBuffer数据结构,数组元素在初始化时一次性全部创建,提升缓存命中率;对象循环利用,避免频繁GC。
|
||||||
|
能够避免伪共享,提升缓存利用率。
|
||||||
|
采用无锁算法,避免频繁加锁、解锁的性能消耗。
|
||||||
|
支持批量消费,消费者可以无锁方式消费多个消息。
|
||||||
|
|
||||||
|
|
||||||
|
其中,前三点涉及到的知识比较多,所以今天咱们重点讲解前三点,不过在详细介绍这些知识之前,我们先来聊聊Disruptor如何使用,好让你先对Disruptor有个感官的认识。
|
||||||
|
|
||||||
|
下面的代码出自官方示例,我略做了一些修改,相较而言,Disruptor的使用比Java SDK提供BlockingQueue要复杂一些,但是总体思路还是一致的,其大致情况如下:
|
||||||
|
|
||||||
|
|
||||||
|
在Disruptor中,生产者生产的对象(也就是消费者消费的对象)称为Event,使用Disruptor必须自定义Event,例如示例代码的自定义Event是LongEvent;
|
||||||
|
|
||||||
|
构建Disruptor对象除了要指定队列大小外,还需要传入一个EventFactory,示例代码中传入的是LongEvent::new;
|
||||||
|
|
||||||
|
消费Disruptor中的Event需要通过handleEventsWith()方法注册一个事件处理器,发布Event则需要通过publishEvent()方法。
|
||||||
|
|
||||||
|
//自定义Event
|
||||||
|
class LongEvent {
|
||||||
|
private long value;
|
||||||
|
public void set(long value) {
|
||||||
|
|
||||||
|
this.value = value;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//指定RingBuffer大小,
|
||||||
|
//必须是2的N次方
|
||||||
|
int bufferSize = 1024;
|
||||||
|
|
||||||
|
//构建Disruptor
|
||||||
|
Disruptor disruptor
|
||||||
|
= new Disruptor<>(
|
||||||
|
|
||||||
|
LongEvent::new,
|
||||||
|
bufferSize,
|
||||||
|
DaemonThreadFactory.INSTANCE);
|
||||||
|
|
||||||
|
//注册事件处理器
|
||||||
|
disruptor.handleEventsWith(
|
||||||
|
(event, sequence, endOfBatch) ->
|
||||||
|
|
||||||
|
System.out.println("E: "+event));
|
||||||
|
|
||||||
|
//启动Disruptor
|
||||||
|
disruptor.start();
|
||||||
|
|
||||||
|
//获取RingBuffer
|
||||||
|
RingBuffer ringBuffer
|
||||||
|
= disruptor.getRingBuffer();
|
||||||
|
//生产Event
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(8);
|
||||||
|
for (long l = 0; true; l++){
|
||||||
|
bb.putLong(0, l);
|
||||||
|
//生产者生产消息
|
||||||
|
ringBuffer.publishEvent(
|
||||||
|
|
||||||
|
(event, sequence, buffer) ->
|
||||||
|
event.set(buffer.getLong(0)), bb);
|
||||||
|
|
||||||
|
Thread.sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
RingBuffer如何提升性能
|
||||||
|
|
||||||
|
Java SDK中ArrayBlockingQueue使用数组作为底层的数据存储,而Disruptor是使用RingBuffer作为数据存储。RingBuffer本质上也是数组,所以仅仅将数据存储从数组换成RingBuffer并不能提升性能,但是Disruptor在RingBuffer的基础上还做了很多优化,其中一项优化就是和内存分配有关的。
|
||||||
|
|
||||||
|
在介绍这项优化之前,你需要先了解一下程序的局部性原理。简单来讲,程序的局部性原理指的是在一段时间内程序的执行会限定在一个局部范围内。这里的“局部性”可以从两个方面来理解,一个是时间局部性,另一个是空间局部性。时间局部性指的是程序中的某条指令一旦被执行,不久之后这条指令很可能再次被执行;如果某条数据被访问,不久之后这条数据很可能再次被访问。而空间局部性是指某块内存一旦被访问,不久之后这块内存附近的内存也很可能被访问。
|
||||||
|
|
||||||
|
CPU的缓存就利用了程序的局部性原理:CPU从内存中加载数据X时,会将数据X缓存在高速缓存Cache中,实际上CPU缓存X的同时,还缓存了X周围的数据,因为根据程序具备局部性原理,X周围的数据也很有可能被访问。从另外一个角度来看,如果程序能够很好地体现出局部性原理,也就能更好地利用CPU的缓存,从而提升程序的性能。Disruptor在设计RingBuffer的时候就充分考虑了这个问题,下面我们就对比着ArrayBlockingQueue来分析一下。
|
||||||
|
|
||||||
|
首先是ArrayBlockingQueue。生产者线程向ArrayBlockingQueue增加一个元素,每次增加元素E之前,都需要创建一个对象E,如下图所示,ArrayBlockingQueue内部有6个元素,这6个元素都是由生产者线程创建的,由于创建这些元素的时间基本上是离散的,所以这些元素的内存地址大概率也不是连续的。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ArrayBlockingQueue内部结构图
|
||||||
|
|
||||||
|
下面我们再看看Disruptor是如何处理的。Disruptor内部的RingBuffer也是用数组实现的,但是这个数组中的所有元素在初始化时是一次性全部创建的,所以这些元素的内存地址大概率是连续的,相关的代码如下所示。
|
||||||
|
|
||||||
|
for (int i=0; i<bufferSize; i++){
|
||||||
|
//entries[]就是RingBuffer内部的数组
|
||||||
|
//eventFactory就是前面示例代码中传入的LongEvent::new
|
||||||
|
entries[BUFFER_PAD + i]
|
||||||
|
= eventFactory.newInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Disruptor内部RingBuffer的结构可以简化成下图,那问题来了,数组中所有元素内存地址连续能提升性能吗?能!为什么呢?因为消费者线程在消费的时候,是遵循空间局部性原理的,消费完第1个元素,很快就会消费第2个元素;当消费第1个元素E1的时候,CPU会把内存中E1后面的数据也加载进Cache,如果E1和E2在内存中的地址是连续的,那么E2也就会被加载进Cache中,然后当消费第2个元素的时候,由于E2已经在Cache中了,所以就不需要从内存中加载了,这样就能大大提升性能。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Disruptor内部RingBuffer结构图
|
||||||
|
|
||||||
|
除此之外,在Disruptor中,生产者线程通过publishEvent()发布Event的时候,并不是创建一个新的Event,而是通过event.set()方法修改Event, 也就是说RingBuffer创建的Event是可以循环利用的,这样还能避免频繁创建、删除Event导致的频繁GC问题。
|
||||||
|
|
||||||
|
如何避免“伪共享”
|
||||||
|
|
||||||
|
高效利用Cache,能够大大提升性能,所以要努力构建能够高效利用Cache的内存结构。而从另外一个角度看,努力避免不能高效利用Cache的内存结构也同样重要。
|
||||||
|
|
||||||
|
有一种叫做“伪共享(False sharing)”的内存布局就会使Cache失效,那什么是“伪共享”呢?
|
||||||
|
|
||||||
|
伪共享和CPU内部的Cache有关,Cache内部是按照缓存行(Cache Line)管理的,缓存行的大小通常是64个字节;CPU从内存中加载数据X,会同时加载X后面(64-size(X))个字节的数据。下面的示例代码出自Java SDK的ArrayBlockingQueue,其内部维护了4个成员变量,分别是队列数组items、出队索引takeIndex、入队索引putIndex以及队列中的元素总数count。
|
||||||
|
|
||||||
|
/** 队列数组 */
|
||||||
|
final Object[] items;
|
||||||
|
/** 出队索引 */
|
||||||
|
int takeIndex;
|
||||||
|
/** 入队索引 */
|
||||||
|
int putIndex;
|
||||||
|
/** 队列中元素总数 */
|
||||||
|
int count;
|
||||||
|
|
||||||
|
|
||||||
|
当CPU从内存中加载takeIndex的时候,会同时将putIndex以及count都加载进Cache。下图是某个时刻CPU中Cache的状况,为了简化,缓存行中我们仅列出了takeIndex和putIndex。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
CPU缓存示意图
|
||||||
|
|
||||||
|
假设线程A运行在CPU-1上,执行入队操作,入队操作会修改putIndex,而修改putIndex会导致其所在的所有核上的缓存行均失效;此时假设运行在CPU-2上的线程执行出队操作,出队操作需要读取takeIndex,由于takeIndex所在的缓存行已经失效,所以CPU-2必须从内存中重新读取。入队操作本不会修改takeIndex,但是由于takeIndex和putIndex共享的是一个缓存行,就导致出队操作不能很好地利用Cache,这其实就是伪共享。简单来讲,伪共享指的是由于共享缓存行导致缓存无效的场景。
|
||||||
|
|
||||||
|
ArrayBlockingQueue的入队和出队操作是用锁来保证互斥的,所以入队和出队不会同时发生。如果允许入队和出队同时发生,那就会导致线程A和线程B争用同一个缓存行,这样也会导致性能问题。所以为了更好地利用缓存,我们必须避免伪共享,那如何避免呢?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
CPU缓存失效示意图
|
||||||
|
|
||||||
|
方案很简单,每个变量独占一个缓存行、不共享缓存行就可以了,具体技术是缓存行填充。比如想让takeIndex独占一个缓存行,可以在takeIndex的前后各填充56个字节,这样就一定能保证takeIndex独占一个缓存行。下面的示例代码出自Disruptor,Sequence 对象中的value属性就能避免伪共享,因为这个属性前后都填充了56个字节。Disruptor中很多对象,例如RingBuffer、RingBuffer内部的数组都用到了这种填充技术来避免伪共享。
|
||||||
|
|
||||||
|
//前:填充56字节
|
||||||
|
class LhsPadding{
|
||||||
|
long p1, p2, p3, p4, p5, p6, p7;
|
||||||
|
}
|
||||||
|
class Value extends LhsPadding{
|
||||||
|
volatile long value;
|
||||||
|
}
|
||||||
|
//后:填充56字节
|
||||||
|
class RhsPadding extends Value{
|
||||||
|
long p9, p10, p11, p12, p13, p14, p15;
|
||||||
|
}
|
||||||
|
class Sequence extends RhsPadding{
|
||||||
|
//省略实现
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Disruptor中的无锁算法
|
||||||
|
|
||||||
|
ArrayBlockingQueue是利用管程实现的,中规中矩,生产、消费操作都需要加锁,实现起来简单,但是性能并不十分理想。Disruptor采用的是无锁算法,很复杂,但是核心无非是生产和消费两个操作。Disruptor中最复杂的是入队操作,所以我们重点来看看入队操作是如何实现的。
|
||||||
|
|
||||||
|
对于入队操作,最关键的要求是不能覆盖没有消费的元素;对于出队操作,最关键的要求是不能读取没有写入的元素,所以Disruptor中也一定会维护类似出队索引和入队索引这样两个关键变量。Disruptor中的RingBuffer维护了入队索引,但是并没有维护出队索引,这是因为在Disruptor中多个消费者可以同时消费,每个消费者都会有一个出队索引,所以RingBuffer的出队索引是所有消费者里面最小的那一个。
|
||||||
|
|
||||||
|
下面是Disruptor生产者入队操作的核心代码,看上去很复杂,其实逻辑很简单:如果没有足够的空余位置,就出让CPU使用权,然后重新计算;反之则用CAS设置入队索引。
|
||||||
|
|
||||||
|
//生产者获取n个写入位置
|
||||||
|
do {
|
||||||
|
//cursor类似于入队索引,指的是上次生产到这里
|
||||||
|
current = cursor.get();
|
||||||
|
//目标是在生产n个
|
||||||
|
next = current + n;
|
||||||
|
//减掉一个循环
|
||||||
|
long wrapPoint = next - bufferSize;
|
||||||
|
//获取上一次的最小消费位置
|
||||||
|
long cachedGatingSequence = gatingSequenceCache.get();
|
||||||
|
//没有足够的空余位置
|
||||||
|
if (wrapPoint>cachedGatingSequence || cachedGatingSequence>current){
|
||||||
|
//重新计算所有消费者里面的最小值位置
|
||||||
|
long gatingSequence = Util.getMinimumSequence(
|
||||||
|
gatingSequences, current);
|
||||||
|
//仍然没有足够的空余位置,出让CPU使用权,重新执行下一循环
|
||||||
|
if (wrapPoint > gatingSequence){
|
||||||
|
LockSupport.parkNanos(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
//从新设置上一次的最小消费位置
|
||||||
|
gatingSequenceCache.set(gatingSequence);
|
||||||
|
} else if (cursor.compareAndSet(current, next)){
|
||||||
|
//获取写入位置成功,跳出循环
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} while (true);
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Disruptor在优化并发性能方面可谓是做到了极致,优化的思路大体是两个方面,一个是利用无锁算法避免锁的争用,另外一个则是将硬件(CPU)的性能发挥到极致。尤其是后者,在Java领域基本上属于经典之作了。
|
||||||
|
|
||||||
|
发挥硬件的能力一般是C这种面向硬件的语言常干的事儿,C语言领域经常通过调整内存布局优化内存占用,而Java领域则用的很少,原因在于Java可以智能地优化内存布局,内存布局对Java程序员的透明的。这种智能的优化大部分场景是很友好的,但是如果你想通过填充方式避免伪共享就必须绕过这种优化,关于这方面Disruptor提供了经典的实现,你可以参考。
|
||||||
|
|
||||||
|
由于伪共享问题如此重要,所以Java也开始重视它了,比如Java 8中,提供了避免伪共享的注解:@sun.misc.Contended,通过这个注解就能轻松避免伪共享(需要设置JVM参数-XX:-RestrictContended)。不过避免伪共享是以牺牲内存为代价的,所以具体使用的时候还是需要仔细斟酌。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
229
专栏/Java并发编程实战/41案例分析(四):高性能数据库连接池HiKariCP.md
Normal file
229
专栏/Java并发编程实战/41案例分析(四):高性能数据库连接池HiKariCP.md
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
41 案例分析(四):高性能数据库连接池HiKariCP
|
||||||
|
实际工作中,我们总会难免和数据库打交道;只要和数据库打交道,就免不了使用数据库连接池。业界知名的数据库连接池有不少,例如c3p0、DBCP、Tomcat JDBC Connection Pool、Druid等,不过最近最火的是HiKariCP。
|
||||||
|
|
||||||
|
HiKariCP号称是业界跑得最快的数据库连接池,这两年发展得顺风顺水,尤其是Springboot 2.0将其作为默认数据库连接池后,江湖一哥的地位已是毋庸置疑了。那它为什么那么快呢?今天咱们就重点聊聊这个话题。
|
||||||
|
|
||||||
|
什么是数据库连接池
|
||||||
|
|
||||||
|
在详细分析HiKariCP高性能之前,我们有必要先简单介绍一下什么是数据库连接池。本质上,数据库连接池和线程池一样,都属于池化资源,作用都是避免重量级资源的频繁创建和销毁,对于数据库连接池来说,也就是避免数据库连接频繁创建和销毁。如下图所示,服务端会在运行期持有一定数量的数据库连接,当需要执行SQL时,并不是直接创建一个数据库连接,而是从连接池中获取一个;当SQL执行完,也并不是将数据库连接真的关掉,而是将其归还到连接池中。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
数据库连接池示意图
|
||||||
|
|
||||||
|
在实际工作中,我们都是使用各种持久化框架来完成数据库的增删改查,基本上不会直接和数据库连接池打交道,为了能让你更好地理解数据库连接池的工作原理,下面的示例代码并没有使用任何框架,而是原生地使用HiKariCP。执行数据库操作基本上是一系列规范化的步骤:
|
||||||
|
|
||||||
|
|
||||||
|
通过数据源获取一个数据库连接;
|
||||||
|
创建Statement;
|
||||||
|
执行SQL;
|
||||||
|
通过ResultSet获取SQL执行结果;
|
||||||
|
释放ResultSet;
|
||||||
|
释放Statement;
|
||||||
|
释放数据库连接。
|
||||||
|
|
||||||
|
|
||||||
|
下面的示例代码,通过 ds.getConnection() 获取一个数据库连接时,其实是向数据库连接池申请一个数据库连接,而不是创建一个新的数据库连接。同样,通过 conn.close() 释放一个数据库连接时,也不是直接将连接关闭,而是将连接归还给数据库连接池。
|
||||||
|
|
||||||
|
//数据库连接池配置
|
||||||
|
HikariConfig config = new HikariConfig();
|
||||||
|
config.setMinimumIdle(1);
|
||||||
|
config.setMaximumPoolSize(2);
|
||||||
|
config.setConnectionTestQuery("SELECT 1");
|
||||||
|
config.setDataSourceClassName("org.h2.jdbcx.JdbcDataSource");
|
||||||
|
config.addDataSourceProperty("url", "jdbc:h2:mem:test");
|
||||||
|
// 创建数据源
|
||||||
|
DataSource ds = new HikariDataSource(config);
|
||||||
|
Connection conn = null;
|
||||||
|
Statement stmt = null;
|
||||||
|
ResultSet rs = null;
|
||||||
|
try {
|
||||||
|
// 获取数据库连接
|
||||||
|
conn = ds.getConnection();
|
||||||
|
// 创建Statement
|
||||||
|
stmt = conn.createStatement();
|
||||||
|
// 执行SQL
|
||||||
|
rs = stmt.executeQuery("select * from abc");
|
||||||
|
// 获取结果
|
||||||
|
while (rs.next()) {
|
||||||
|
int id = rs.getInt(1);
|
||||||
|
......
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
} finally {
|
||||||
|
//关闭ResultSet
|
||||||
|
close(rs);
|
||||||
|
//关闭Statement
|
||||||
|
close(stmt);
|
||||||
|
//关闭Connection
|
||||||
|
close(conn);
|
||||||
|
}
|
||||||
|
//关闭资源
|
||||||
|
void close(AutoCloseable rs) {
|
||||||
|
if (rs != null) {
|
||||||
|
try {
|
||||||
|
rs.close();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
HiKariCP官方网站解释了其性能之所以如此之高的秘密。微观上HiKariCP程序编译出的字节码执行效率更高,站在字节码的角度去优化Java代码,HiKariCP的作者对性能的执着可见一斑,不过遗憾的是他并没有详细解释都做了哪些优化。而宏观上主要是和两个数据结构有关,一个是FastList,另一个是ConcurrentBag。下面我们来看看它们是如何提升HiKariCP的性能的。
|
||||||
|
|
||||||
|
FastList解决了哪些性能问题
|
||||||
|
|
||||||
|
按照规范步骤,执行完数据库操作之后,需要依次关闭ResultSet、Statement、Connection,但是总有粗心的同学只是关闭了Connection,而忘了关闭ResultSet和Statement。为了解决这种问题,最好的办法是当关闭Connection时,能够自动关闭Statement。为了达到这个目标,Connection就需要跟踪创建的Statement,最简单的办法就是将创建的Statement保存在数组ArrayList里,这样当关闭Connection的时候,就可以依次将数组中的所有Statement关闭。
|
||||||
|
|
||||||
|
HiKariCP觉得用ArrayList还是太慢,当通过 conn.createStatement() 创建一个Statement时,需要调用ArrayList的add()方法加入到ArrayList中,这个是没有问题的;但是当通过 stmt.close() 关闭Statement的时候,需要调用 ArrayList的remove()方法来将其从ArrayList中删除,这里是有优化余地的。
|
||||||
|
|
||||||
|
假设一个Connection依次创建6个Statement,分别是S1、S2、S3、S4、S5、S6,按照正常的编码习惯,关闭Statement的顺序一般是逆序的,关闭的顺序是:S6、S5、S4、S3、S2、S1,而ArrayList的remove(Object o)方法是顺序遍历查找,逆序删除而顺序查找,这样的查找效率就太慢了。如何优化呢?很简单,优化成逆序查找就可以了。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
逆序删除示意图
|
||||||
|
|
||||||
|
HiKariCP中的FastList相对于ArrayList的一个优化点就是将 remove(Object element) 方法的查找顺序变成了逆序查找。除此之外,FastList还有另一个优化点,是 get(int index) 方法没有对index参数进行越界检查,HiKariCP能保证不会越界,所以不用每次都进行越界检查。
|
||||||
|
|
||||||
|
整体来看,FastList的优化点还是很简单的。下面我们再来聊聊HiKariCP中的另外一个数据结构ConcurrentBag,看看它又是如何提升性能的。
|
||||||
|
|
||||||
|
ConcurrentBag解决了哪些性能问题
|
||||||
|
|
||||||
|
如果让我们自己来实现一个数据库连接池,最简单的办法就是用两个阻塞队列来实现,一个用于保存空闲数据库连接的队列idle,另一个用于保存忙碌数据库连接的队列busy;获取连接时将空闲的数据库连接从idle队列移动到busy队列,而关闭连接时将数据库连接从busy移动到idle。这种方案将并发问题委托给了阻塞队列,实现简单,但是性能并不是很理想。因为Java SDK中的阻塞队列是用锁实现的,而高并发场景下锁的争用对性能影响很大。
|
||||||
|
|
||||||
|
//忙碌队列
|
||||||
|
BlockingQueue<Connection> busy;
|
||||||
|
//空闲队列
|
||||||
|
BlockingQueue<Connection> idle;
|
||||||
|
|
||||||
|
|
||||||
|
HiKariCP并没有使用Java SDK中的阻塞队列,而是自己实现了一个叫做ConcurrentBag的并发容器。ConcurrentBag的设计最初源自C#,它的一个核心设计是使用ThreadLocal避免部分并发问题,不过HiKariCP中的ConcurrentBag并没有完全参考C#的实现,下面我们来看看它是如何实现的。
|
||||||
|
|
||||||
|
ConcurrentBag中最关键的属性有4个,分别是:用于存储所有的数据库连接的共享队列sharedList、线程本地存储threadList、等待数据库连接的线程数waiters以及分配数据库连接的工具handoffQueue。其中,handoffQueue用的是Java SDK提供的SynchronousQueue,SynchronousQueue主要用于线程之间传递数据。
|
||||||
|
|
||||||
|
//用于存储所有的数据库连接
|
||||||
|
CopyOnWriteArrayList<T> sharedList;
|
||||||
|
//线程本地存储中的数据库连接
|
||||||
|
ThreadLocal<List<Object>> threadList;
|
||||||
|
//等待数据库连接的线程数
|
||||||
|
AtomicInteger waiters;
|
||||||
|
//分配数据库连接的工具
|
||||||
|
SynchronousQueue<T> handoffQueue;
|
||||||
|
|
||||||
|
|
||||||
|
当线程池创建了一个数据库连接时,通过调用ConcurrentBag的add()方法加入到ConcurrentBag中,下面是add()方法的具体实现,逻辑很简单,就是将这个连接加入到共享队列sharedList中,如果此时有线程在等待数据库连接,那么就通过handoffQueue将这个连接分配给等待的线程。
|
||||||
|
|
||||||
|
//将空闲连接添加到队列
|
||||||
|
void add(final T bagEntry){
|
||||||
|
//加入共享队列
|
||||||
|
sharedList.add(bagEntry);
|
||||||
|
//如果有等待连接的线程,
|
||||||
|
//则通过handoffQueue直接分配给等待的线程
|
||||||
|
while (waiters.get() > 0
|
||||||
|
&& bagEntry.getState() == STATE_NOT_IN_USE
|
||||||
|
&& !handoffQueue.offer(bagEntry)) {
|
||||||
|
yield();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
通过ConcurrentBag提供的borrow()方法,可以获取一个空闲的数据库连接,borrow()的主要逻辑是:
|
||||||
|
|
||||||
|
|
||||||
|
首先查看线程本地存储是否有空闲连接,如果有,则返回一个空闲的连接;
|
||||||
|
如果线程本地存储中无空闲连接,则从共享队列中获取。
|
||||||
|
如果共享队列中也没有空闲的连接,则请求线程需要等待。
|
||||||
|
|
||||||
|
|
||||||
|
需要注意的是,线程本地存储中的连接是可以被其他线程窃取的,所以需要用CAS方法防止重复分配。在共享队列中获取空闲连接,也采用了CAS方法防止重复分配。
|
||||||
|
|
||||||
|
T borrow(long timeout, final TimeUnit timeUnit){
|
||||||
|
// 先查看线程本地存储是否有空闲连接
|
||||||
|
final List<Object> list = threadList.get();
|
||||||
|
for (int i = list.size() - 1; i >= 0; i--) {
|
||||||
|
final Object entry = list.remove(i);
|
||||||
|
final T bagEntry = weakThreadLocals
|
||||||
|
? ((WeakReference<T>) entry).get()
|
||||||
|
: (T) entry;
|
||||||
|
//线程本地存储中的连接也可以被窃取,
|
||||||
|
//所以需要用CAS方法防止重复分配
|
||||||
|
if (bagEntry != null
|
||||||
|
&& bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
|
||||||
|
return bagEntry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 线程本地存储中无空闲连接,则从共享队列中获取
|
||||||
|
final int waiting = waiters.incrementAndGet();
|
||||||
|
try {
|
||||||
|
for (T bagEntry : sharedList) {
|
||||||
|
//如果共享队列中有空闲连接,则返回
|
||||||
|
if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
|
||||||
|
return bagEntry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//共享队列中没有连接,则需要等待
|
||||||
|
timeout = timeUnit.toNanos(timeout);
|
||||||
|
do {
|
||||||
|
final long start = currentTime();
|
||||||
|
final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
|
||||||
|
if (bagEntry == null
|
||||||
|
|| bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
|
||||||
|
return bagEntry;
|
||||||
|
}
|
||||||
|
//重新计算等待时间
|
||||||
|
timeout -= elapsedNanos(start);
|
||||||
|
} while (timeout > 10_000);
|
||||||
|
//超时没有获取到连接,返回null
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
waiters.decrementAndGet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
释放连接需要调用ConcurrentBag提供的requite()方法,该方法的逻辑很简单,首先将数据库连接状态更改为STATE_NOT_IN_USE,之后查看是否存在等待线程,如果有,则分配给等待线程;如果没有,则将该数据库连接保存到线程本地存储里。
|
||||||
|
|
||||||
|
//释放连接
|
||||||
|
void requite(final T bagEntry){
|
||||||
|
//更新连接状态
|
||||||
|
bagEntry.setState(STATE_NOT_IN_USE);
|
||||||
|
//如果有等待的线程,则直接分配给线程,无需进入任何队列
|
||||||
|
for (int i = 0; waiters.get() > 0; i++) {
|
||||||
|
if (bagEntry.getState() != STATE_NOT_IN_USE
|
||||||
|
|| handoffQueue.offer(bagEntry)) {
|
||||||
|
return;
|
||||||
|
} else if ((i & 0xff) == 0xff) {
|
||||||
|
parkNanos(MICROSECONDS.toNanos(10));
|
||||||
|
} else {
|
||||||
|
yield();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//如果没有等待的线程,则进入线程本地存储
|
||||||
|
final List<Object> threadLocalList = threadList.get();
|
||||||
|
if (threadLocalList.size() < 50) {
|
||||||
|
threadLocalList.add(weakThreadLocals
|
||||||
|
? new WeakReference<>(bagEntry)
|
||||||
|
: bagEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
HiKariCP中的FastList和ConcurrentBag这两个数据结构使用得非常巧妙,虽然实现起来并不复杂,但是对于性能的提升非常明显,根本原因在于这两个数据结构适用于数据库连接池这个特定的场景。FastList适用于逆序删除场景;而ConcurrentBag通过ThreadLocal做一次预分配,避免直接竞争共享资源,非常适合池化资源的分配。
|
||||||
|
|
||||||
|
在实际工作中,我们遇到的并发问题千差万别,这时选择合适的并发数据结构就非常重要了。当然能选对的前提是对特定场景的并发特性有深入的了解,只有了解到无谓的性能消耗在哪里,才能对症下药。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
133
专栏/Java并发编程实战/42Actor模型:面向对象原生的并发模型.md
Normal file
133
专栏/Java并发编程实战/42Actor模型:面向对象原生的并发模型.md
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
42 Actor模型:面向对象原生的并发模型
|
||||||
|
上学的时候,有门计算机专业课叫做面向对象编程,学这门课的时候有个问题困扰了我很久,按照面向对象编程的理论,对象之间通信需要依靠消息,而实际上,像C++、Java这些面向对象的语言,对象之间通信,依靠的是对象方法。对象方法和过程语言里的函数本质上没有区别,有入参、有出参,思维方式很相似,使用起来都很简单。那面向对象理论里的消息是否就等价于面向对象语言里的对象方法呢?很长一段时间里,我都以为对象方法是面向对象理论中消息的一种实现,直到接触到Actor模型,才明白消息压根不是这个实现法。
|
||||||
|
|
||||||
|
Hello Actor模型
|
||||||
|
|
||||||
|
Actor模型本质上是一种计算模型,基本的计算单元称为Actor,换言之,在Actor模型中,所有的计算都是在Actor中执行的。在面向对象编程里面,一切都是对象;在Actor模型里,一切都是Actor,并且Actor之间是完全隔离的,不会共享任何变量。
|
||||||
|
|
||||||
|
当看到“不共享任何变量”的时候,相信你一定会眼前一亮,并发问题的根源就在于共享变量,而Actor模型中Actor之间不共享变量,那用Actor模型解决并发问题,一定是相当顺手。的确是这样,所以很多人就把Actor模型定义为一种并发计算模型。其实Actor模型早在1973年就被提出来了,只是直到最近几年才被广泛关注,一个主要原因就在于它是解决并发问题的利器,而最近几年随着多核处理器的发展,并发问题被推到了风口浪尖上。
|
||||||
|
|
||||||
|
但是Java语言本身并不支持Actor模型,所以如果你想在Java语言里使用Actor模型,就需要借助第三方类库,目前能完备地支持Actor模型而且比较成熟的类库就是Akka了。在详细介绍Actor模型之前,我们就先基于Akka写一个Hello World程序,让你对Actor模型先有个感官的印象。
|
||||||
|
|
||||||
|
在下面的示例代码中,我们首先创建了一个ActorSystem(Actor不能脱离ActorSystem存在);之后创建了一个HelloActor,Akka中创建Actor并不是new一个对象出来,而是通过调用system.actorOf()方法创建的,该方法返回的是ActorRef,而不是HelloActor;最后通过调用ActorRef的tell()方法给HelloActor发送了一条消息 “Actor” 。
|
||||||
|
|
||||||
|
//该Actor当收到消息message后,
|
||||||
|
//会打印Hello message
|
||||||
|
static class HelloActor
|
||||||
|
extends UntypedActor {
|
||||||
|
@Override
|
||||||
|
public void onReceive(Object message) {
|
||||||
|
System.out.println("Hello " + message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
//创建Actor系统
|
||||||
|
ActorSystem system = ActorSystem.create("HelloSystem");
|
||||||
|
//创建HelloActor
|
||||||
|
ActorRef helloActor =
|
||||||
|
system.actorOf(Props.create(HelloActor.class));
|
||||||
|
//发送消息给HelloActor
|
||||||
|
helloActor.tell("Actor", ActorRef.noSender());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
通过这个例子,你会发现Actor模型和面向对象编程契合度非常高,完全可以用Actor类比面向对象编程里面的对象,而且Actor之间的通信方式完美地遵守了消息机制,而不是通过对象方法来实现对象之间的通信。那Actor中的消息机制和面向对象语言里的对象方法有什么区别呢?
|
||||||
|
|
||||||
|
消息和对象方法的区别
|
||||||
|
|
||||||
|
在没有计算机的时代,异地的朋友往往是通过写信来交流感情的,但信件发出去之后,也许会在寄送过程中弄丢了,也有可能寄到后,对方一直没有时间写回信……这个时候都可以让邮局“背个锅”,不过无论如何,也不过是重写一封,生活继续。
|
||||||
|
|
||||||
|
Actor中的消息机制,就可以类比这现实世界里的写信。Actor内部有一个邮箱(Mailbox),接收到的消息都是先放到邮箱里,如果邮箱里有积压的消息,那么新收到的消息就不会马上得到处理,也正是因为Actor使用单线程处理消息,所以不会出现并发问题。你可以把Actor内部的工作模式想象成只有一个消费者线程的生产者-消费者模式。
|
||||||
|
|
||||||
|
所以,在Actor模型里,发送消息仅仅是把消息发出去而已,接收消息的Actor在接收到消息后,也不一定会立即处理,也就是说Actor中的消息机制完全是异步的。而调用对象方法,实际上是同步的,对象方法return之前,调用方会一直等待。
|
||||||
|
|
||||||
|
除此之外,调用对象方法,需要持有对象的引用,所有的对象必须在同一个进程中。而在Actor中发送消息,类似于现实中的写信,只需要知道对方的地址就可以,发送消息和接收消息的Actor可以不在一个进程中,也可以不在同一台机器上。因此,Actor模型不但适用于并发计算,还适用于分布式计算。
|
||||||
|
|
||||||
|
Actor的规范化定义
|
||||||
|
|
||||||
|
通过上面的介绍,相信你应该已经对Actor有一个感官印象了,下面我们再来看看Actor规范化的定义是什么样的。Actor是一种基础的计算单元,具体来讲包括三部分能力,分别是:
|
||||||
|
|
||||||
|
|
||||||
|
处理能力,处理接收到的消息。
|
||||||
|
存储能力,Actor可以存储自己的内部状态,并且内部状态在不同Actor之间是绝对隔离的。
|
||||||
|
通信能力,Actor可以和其他Actor之间通信。
|
||||||
|
|
||||||
|
|
||||||
|
当一个Actor接收的一条消息之后,这个Actor可以做以下三件事:
|
||||||
|
|
||||||
|
|
||||||
|
创建更多的Actor;
|
||||||
|
发消息给其他Actor;
|
||||||
|
确定如何处理下一条消息。
|
||||||
|
|
||||||
|
|
||||||
|
其中前两条还是很好理解的,就是最后一条,该如何去理解呢?前面我们说过Actor具备存储能力,它有自己的内部状态,所以你也可以把Actor看作一个状态机,把Actor处理消息看作是触发状态机的状态变化;而状态机的变化往往要基于上一个状态,触发状态机发生变化的时刻,上一个状态必须是确定的,所以确定如何处理下一条消息,本质上不过是改变内部状态。
|
||||||
|
|
||||||
|
在多线程里面,由于可能存在竞态条件,所以根据当前状态确定如何处理下一条消息还是有难度的,需要使用各种同步工具,但在Actor模型里,由于是单线程处理,所以就不存在竞态条件问题了。
|
||||||
|
|
||||||
|
用Actor实现累加器
|
||||||
|
|
||||||
|
支持并发的累加器可能是最简单并且有代表性的并发问题了,可以基于互斥锁方案实现,也可以基于原子类实现,但今天我们要尝试用Actor来实现。
|
||||||
|
|
||||||
|
在下面的示例代码中,CounterActor内部持有累计值counter,当CounterActor接收到一个数值型的消息message时,就将累计值counter += message;但如果是其他类型的消息,则打印当前累计值counter。在main()方法中,我们启动了4个线程来执行累加操作。整个程序没有锁,也没有CAS,但是程序是线程安全的。
|
||||||
|
|
||||||
|
//累加器
|
||||||
|
static class CounterActor extends UntypedActor {
|
||||||
|
private int counter = 0;
|
||||||
|
@Override
|
||||||
|
public void onReceive(Object message){
|
||||||
|
//如果接收到的消息是数字类型,执行累加操作,
|
||||||
|
//否则打印counter的值
|
||||||
|
if (message instanceof Number) {
|
||||||
|
counter += ((Number) message).intValue();
|
||||||
|
} else {
|
||||||
|
System.out.println(counter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static void main(String[] args) throws InterruptedException {
|
||||||
|
//创建Actor系统
|
||||||
|
ActorSystem system = ActorSystem.create("HelloSystem");
|
||||||
|
//4个线程生产消息
|
||||||
|
ExecutorService es = Executors.newFixedThreadPool(4);
|
||||||
|
//创建CounterActor
|
||||||
|
ActorRef counterActor =
|
||||||
|
system.actorOf(Props.create(CounterActor.class));
|
||||||
|
//生产4*100000个消息
|
||||||
|
for (int i=0; i<4; i++) {
|
||||||
|
es.execute(()->{
|
||||||
|
for (int j=0; j<100000; j++) {
|
||||||
|
counterActor.tell(1, ActorRef.noSender());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//关闭线程池
|
||||||
|
es.shutdown();
|
||||||
|
//等待CounterActor处理完所有消息
|
||||||
|
Thread.sleep(1000);
|
||||||
|
//打印结果
|
||||||
|
counterActor.tell("", ActorRef.noSender());
|
||||||
|
//关闭Actor系统
|
||||||
|
system.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Actor模型是一种非常简单的计算模型,其中Actor是最基本的计算单元,Actor之间是通过消息进行通信。Actor与面向对象编程(OOP)中的对象匹配度非常高,在面向对象编程里,系统由类似于生物细胞那样的对象构成,对象之间也是通过消息进行通信,所以在面向对象语言里使用Actor模型基本上不会有违和感。
|
||||||
|
|
||||||
|
在Java领域,除了可以使用Akka来支持Actor模型外,还可以使用Vert.x,不过相对来说Vert.x更像是Actor模型的隐式实现,对应关系不像Akka那样明显,不过本质上也是一种Actor模型。
|
||||||
|
|
||||||
|
Actor可以创建新的Actor,这些Actor最终会呈现出一个树状结构,非常像现实世界里的组织结构,所以利用Actor模型来对程序进行建模,和现实世界的匹配度非常高。Actor模型和现实世界一样都是异步模型,理论上不保证消息百分百送达,也不保证消息送达的顺序和发送的顺序是一致的,甚至无法保证消息会被百分百处理。虽然实现Actor模型的厂商都在试图解决这些问题,但遗憾的是解决得并不完美,所以使用Actor模型也是有成本的。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
247
专栏/Java并发编程实战/43软件事务内存:借鉴数据库的并发经验.md
Normal file
247
专栏/Java并发编程实战/43软件事务内存:借鉴数据库的并发经验.md
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
43 软件事务内存:借鉴数据库的并发经验
|
||||||
|
很多同学反馈说,工作了挺长时间但是没有机会接触并发编程,实际上我们天天都在写并发程序,只不过并发相关的问题都被类似Tomcat这样的Web服务器以及MySQL这样的数据库解决了。尤其是数据库,在解决并发问题方面,可谓成绩斐然,它的事务机制非常简单易用,能甩Java里面的锁、原子类十条街。技术无边界,很显然要借鉴一下。
|
||||||
|
|
||||||
|
其实很多编程语言都有从数据库的事务管理中获得灵感,并且总结出了一个新的并发解决方案:软件事务内存(Software Transactional Memory,简称STM)。传统的数据库事务,支持4个特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),也就是大家常说的ACID,STM由于不涉及到持久化,所以只支持ACI。
|
||||||
|
|
||||||
|
STM的使用很简单,下面我们以经典的转账操作为例,看看用STM该如何实现。
|
||||||
|
|
||||||
|
用STM实现转账
|
||||||
|
|
||||||
|
我们曾经在《05 | 一不小心就死锁了,怎么办?》这篇文章中,讲到了并发转账的例子,示例代码如下。简单地使用 synchronized 将 transfer() 方法变成同步方法并不能解决并发问题,因为还存在死锁问题。
|
||||||
|
|
||||||
|
class UnsafeAccount {
|
||||||
|
//余额
|
||||||
|
private long balance;
|
||||||
|
//构造函数
|
||||||
|
public UnsafeAccount(long balance) {
|
||||||
|
this.balance = balance;
|
||||||
|
}
|
||||||
|
//转账
|
||||||
|
void transfer(UnsafeAccount target, long amt){
|
||||||
|
if (this.balance > amt) {
|
||||||
|
this.balance -= amt;
|
||||||
|
target.balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
该转账操作若使用数据库事务就会非常简单,如下面的示例代码所示。如果所有SQL都正常执行,则通过 commit() 方法提交事务;如果SQL在执行过程中有异常,则通过 rollback() 方法回滚事务。数据库保证在并发情况下不会有死锁,而且还能保证前面我们说的原子性、一致性、隔离性和持久性,也就是ACID。
|
||||||
|
|
||||||
|
Connection conn = null;
|
||||||
|
try{
|
||||||
|
//获取数据库连接
|
||||||
|
conn = DriverManager.getConnection();
|
||||||
|
//设置手动提交事务
|
||||||
|
conn.setAutoCommit(false);
|
||||||
|
//执行转账SQL
|
||||||
|
......
|
||||||
|
//提交事务
|
||||||
|
conn.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
//出现异常回滚事务
|
||||||
|
conn.rollback();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
那如果用STM又该如何实现呢?Java语言并不支持STM,不过可以借助第三方的类库来支持,Multiverse就是个不错的选择。下面的示例代码就是借助Multiverse实现了线程安全的转账操作,相比较上面线程不安全的UnsafeAccount,其改动并不大,仅仅是将余额的类型从 long 变成了 TxnLong ,将转账的操作放到了 atomic(()->{}) 中。
|
||||||
|
|
||||||
|
class Account{
|
||||||
|
//余额
|
||||||
|
private TxnLong balance;
|
||||||
|
//构造函数
|
||||||
|
public Account(long balance){
|
||||||
|
this.balance = StmUtils.newTxnLong(balance);
|
||||||
|
}
|
||||||
|
//转账
|
||||||
|
public void transfer(Account to, int amt){
|
||||||
|
//原子化操作
|
||||||
|
atomic(()->{
|
||||||
|
if (this.balance.get() > amt) {
|
||||||
|
this.balance.decrement(amt);
|
||||||
|
to.balance.increment(amt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
一个关键的atomic()方法就把并发问题解决了,这个方案看上去比传统的方案的确简单了很多,那它是如何实现的呢?数据库事务发展了几十年了,目前被广泛使用的是MVCC(全称是Multi-Version Concurrency Control),也就是多版本并发控制。
|
||||||
|
|
||||||
|
MVCC可以简单地理解为数据库事务在开启的时候,会给数据库打一个快照,以后所有的读写都是基于这个快照的。当提交事务的时候,如果所有读写过的数据在该事务执行期间没有发生过变化,那么就可以提交;如果发生了变化,说明该事务和有其他事务读写的数据冲突了,这个时候是不可以提交的。
|
||||||
|
|
||||||
|
为了记录数据是否发生了变化,可以给每条数据增加一个版本号,这样每次成功修改数据都会增加版本号的值。MVCC的工作原理和我们曾经在《18 | StampedLock:有没有比读写锁更快的锁?》中提到的乐观锁非常相似。有不少STM的实现方案都是基于MVCC的,例如知名的Clojure STM。
|
||||||
|
|
||||||
|
下面我们就用最简单的代码基于MVCC实现一个简版的STM,这样你会对STM以及MVCC的工作原理有更深入的认识。
|
||||||
|
|
||||||
|
自己实现STM
|
||||||
|
|
||||||
|
我们首先要做的,就是让Java中的对象有版本号,在下面的示例代码中,VersionedRef这个类的作用就是将对象value包装成带版本号的对象。按照MVCC理论,数据的每一次修改都对应着一个唯一的版本号,所以不存在仅仅改变value或者version的情况,用不变性模式就可以很好地解决这个问题,所以VersionedRef这个类被我们设计成了不可变的。
|
||||||
|
|
||||||
|
所有对数据的读写操作,一定是在一个事务里面,TxnRef这个类负责完成事务内的读写操作,读写操作委托给了接口Txn,Txn代表的是读写操作所在的当前事务, 内部持有的curRef代表的是系统中的最新值。
|
||||||
|
|
||||||
|
//带版本号的对象引用
|
||||||
|
public final class VersionedRef<T> {
|
||||||
|
final T value;
|
||||||
|
final long version;
|
||||||
|
//构造方法
|
||||||
|
public VersionedRef(T value, long version) {
|
||||||
|
this.value = value;
|
||||||
|
this.version = version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//支持事务的引用
|
||||||
|
public class TxnRef<T> {
|
||||||
|
//当前数据,带版本号
|
||||||
|
volatile VersionedRef curRef;
|
||||||
|
//构造方法
|
||||||
|
public TxnRef(T value) {
|
||||||
|
this.curRef = new VersionedRef(value, 0L);
|
||||||
|
}
|
||||||
|
//获取当前事务中的数据
|
||||||
|
public T getValue(Txn txn) {
|
||||||
|
return txn.get(this);
|
||||||
|
}
|
||||||
|
//在当前事务中设置数据
|
||||||
|
public void setValue(T value, Txn txn) {
|
||||||
|
txn.set(this, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
STMTxn是Txn最关键的一个实现类,事务内对于数据的读写,都是通过它来完成的。STMTxn内部有两个Map:inTxnMap,用于保存当前事务中所有读写的数据的快照;writeMap,用于保存当前事务需要写入的数据。每个事务都有一个唯一的事务ID txnId,这个txnId是全局递增的。
|
||||||
|
|
||||||
|
STMTxn有三个核心方法,分别是读数据的get()方法、写数据的set()方法和提交事务的commit()方法。其中,get()方法将要读取数据作为快照放入inTxnMap,同时保证每次读取的数据都是一个版本。set()方法会将要写入的数据放入writeMap,但如果写入的数据没被读取过,也会将其放入 inTxnMap。
|
||||||
|
|
||||||
|
至于commit()方法,我们为了简化实现,使用了互斥锁,所以事务的提交是串行的。commit()方法的实现很简单,首先检查inTxnMap中的数据是否发生过变化,如果没有发生变化,那么就将writeMap中的数据写入(这里的写入其实就是TxnRef内部持有的curRef);如果发生过变化,那么就不能将writeMap中的数据写入了。
|
||||||
|
|
||||||
|
//事务接口
|
||||||
|
public interface Txn {
|
||||||
|
<T> T get(TxnRef<T> ref);
|
||||||
|
<T> void set(TxnRef<T> ref, T value);
|
||||||
|
}
|
||||||
|
//STM事务实现类
|
||||||
|
public final class STMTxn implements Txn {
|
||||||
|
//事务ID生成器
|
||||||
|
private static AtomicLong txnSeq = new AtomicLong(0);
|
||||||
|
|
||||||
|
//当前事务所有的相关数据
|
||||||
|
private Map<TxnRef, VersionedRef> inTxnMap = new HashMap<>();
|
||||||
|
//当前事务所有需要修改的数据
|
||||||
|
private Map<TxnRef, Object> writeMap = new HashMap<>();
|
||||||
|
//当前事务ID
|
||||||
|
private long txnId;
|
||||||
|
//构造函数,自动生成当前事务ID
|
||||||
|
STMTxn() {
|
||||||
|
txnId = txnSeq.incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
//获取当前事务中的数据
|
||||||
|
@Override
|
||||||
|
public <T> T get(TxnRef<T> ref) {
|
||||||
|
//将需要读取的数据,加入inTxnMap
|
||||||
|
if (!inTxnMap.containsKey(ref)) {
|
||||||
|
inTxnMap.put(ref, ref.curRef);
|
||||||
|
}
|
||||||
|
return (T) inTxnMap.get(ref).value;
|
||||||
|
}
|
||||||
|
//在当前事务中修改数据
|
||||||
|
@Override
|
||||||
|
public <T> void set(TxnRef<T> ref, T value) {
|
||||||
|
//将需要修改的数据,加入inTxnMap
|
||||||
|
if (!inTxnMap.containsKey(ref)) {
|
||||||
|
inTxnMap.put(ref, ref.curRef);
|
||||||
|
}
|
||||||
|
writeMap.put(ref, value);
|
||||||
|
}
|
||||||
|
//提交事务
|
||||||
|
boolean commit() {
|
||||||
|
synchronized (STM.commitLock) {
|
||||||
|
//是否校验通过
|
||||||
|
boolean isValid = true;
|
||||||
|
//校验所有读过的数据是否发生过变化
|
||||||
|
for(Map.Entry<TxnRef, VersionedRef> entry : inTxnMap.entrySet()){
|
||||||
|
VersionedRef curRef = entry.getKey().curRef;
|
||||||
|
VersionedRef readRef = entry.getValue();
|
||||||
|
//通过版本号来验证数据是否发生过变化
|
||||||
|
if (curRef.version != readRef.version) {
|
||||||
|
isValid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//如果校验通过,则所有更改生效
|
||||||
|
if (isValid) {
|
||||||
|
writeMap.forEach((k, v) -> {
|
||||||
|
k.curRef = new VersionedRef(v, txnId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
下面我们来模拟实现Multiverse中的原子化操作atomic()。atomic()方法中使用了类似于CAS的操作,如果事务提交失败,那么就重新创建一个新的事务,重新执行。
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface TxnRunnable {
|
||||||
|
void run(Txn txn);
|
||||||
|
}
|
||||||
|
//STM
|
||||||
|
public final class STM {
|
||||||
|
//私有化构造方法
|
||||||
|
private STM() {
|
||||||
|
//提交数据需要用到的全局锁
|
||||||
|
static final Object commitLock = new Object();
|
||||||
|
//原子化提交方法
|
||||||
|
public static void atomic(TxnRunnable action) {
|
||||||
|
boolean committed = false;
|
||||||
|
//如果没有提交成功,则一直重试
|
||||||
|
while (!committed) {
|
||||||
|
//创建新的事务
|
||||||
|
STMTxn txn = new STMTxn();
|
||||||
|
//执行业务逻辑
|
||||||
|
action.run(txn);
|
||||||
|
//提交事务
|
||||||
|
committed = txn.commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
就这样,我们自己实现了STM,并完成了线程安全的转账操作,使用方法和Multiverse差不多,这里就不赘述了,具体代码如下面所示。
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
//余额
|
||||||
|
private TxnRef<Integer> balance;
|
||||||
|
//构造方法
|
||||||
|
public Account(int balance) {
|
||||||
|
this.balance = new TxnRef<Integer>(balance);
|
||||||
|
}
|
||||||
|
//转账操作
|
||||||
|
public void transfer(Account target, int amt){
|
||||||
|
STM.atomic((txn)->{
|
||||||
|
Integer from = balance.getValue(txn);
|
||||||
|
balance.setValue(from-amt, txn);
|
||||||
|
Integer to = target.balance.getValue(txn);
|
||||||
|
target.balance.setValue(to+amt, txn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
STM借鉴的是数据库的经验,数据库虽然复杂,但仅仅存储数据,而编程语言除了有共享变量之外,还会执行各种I/O操作,很显然I/O操作是很难支持回滚的。所以,STM也不是万能的。目前支持STM的编程语言主要是函数式语言,函数式语言里的数据天生具备不可变性,利用这种不可变性实现STM相对来说更简单。
|
||||||
|
|
||||||
|
另外,需要说明的是,文中的“自己实现STM”部分我参考了Software Transactional Memory in Scala这篇博文以及一个GitHub项目,目前还很粗糙,并不是一个完备的MVCC。如果你对这方面感兴趣,可以参考Improving the STM: Multi-Version Concurrency Control 这篇博文,里面讲到了如何优化,你可以尝试学习下。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
152
专栏/Java并发编程实战/44协程:更轻量级的线程.md
Normal file
152
专栏/Java并发编程实战/44协程:更轻量级的线程.md
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
44 协程:更轻量级的线程
|
||||||
|
Java语言里解决并发问题靠的是多线程,但线程是个重量级的对象,不能频繁创建、销毁,而且线程切换的成本也很高,为了解决这些问题,Java SDK提供了线程池。然而用好线程池并不容易,Java围绕线程池提供了很多工具类,这些工具类学起来也不容易。那有没有更好的解决方案呢?Java语言里目前还没有,但是其他语言里有,这个方案就是协程(Coroutine)。
|
||||||
|
|
||||||
|
我们可以把协程简单地理解为一种轻量级的线程。从操作系统的角度来看,线程是在内核态中调度的,而协程是在用户态调度的,所以相对于线程来说,协程切换的成本更低。协程虽然也有自己的栈,但是相比线程栈要小得多,典型的线程栈大小差不多有1M,而协程栈的大小往往只有几K或者几十K。所以,无论是从时间维度还是空间维度来看,协程都比线程轻量得多。
|
||||||
|
|
||||||
|
支持协程的语言还是挺多的,例如Golang、Python、Lua、Kotlin等都支持协程。下面我们就以Golang为代表,看看协程是如何在Golang中使用的。
|
||||||
|
|
||||||
|
Golang中的协程
|
||||||
|
|
||||||
|
在Golang中创建协程非常简单,在下面的示例代码中,要让hello()方法在一个新的协程中执行,只需要go hello("World") 这一行代码就搞定了。你可以对比着想想在Java里是如何“辛勤”地创建线程和线程池的吧,我的感觉一直都是:每次写完Golang的代码,就再也不想写Java代码了。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
func hello(msg string) {
|
||||||
|
fmt.Println("Hello " + msg)
|
||||||
|
}
|
||||||
|
func main() {
|
||||||
|
//在新的协程中执行hello方法
|
||||||
|
go hello("World")
|
||||||
|
fmt.Println("Run in main")
|
||||||
|
//等待100毫秒让协程执行结束
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
我们在《33 | Thread-Per-Message模式:最简单实用的分工方法》中介绍过,利用协程能够很好地实现Thread-Per-Message模式。Thread-Per-Message模式非常简单,其实越是简单的模式,功能上就越稳定,可理解性也越好。
|
||||||
|
|
||||||
|
下面的示例代码是用Golang实现的echo程序的服务端,用的是Thread-Per-Message模式,为每个成功建立连接的socket分配一个协程,相比Java线程池的实现方案,Golang中协程的方案更简单。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
//监听本地9090端口
|
||||||
|
socket, err := net.Listen("tcp", "127.0.0.1:9090")
|
||||||
|
if err != nil {
|
||||||
|
log.Panicln(err)
|
||||||
|
}
|
||||||
|
defer socket.Close()
|
||||||
|
for {
|
||||||
|
//处理连接请求
|
||||||
|
conn, err := socket.Accept()
|
||||||
|
if err != nil {
|
||||||
|
log.Panicln(err)
|
||||||
|
}
|
||||||
|
//处理已经成功建立连接的请求
|
||||||
|
go handleRequest(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//处理已经成功建立连接的请求
|
||||||
|
func handleRequest(conn net.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
for {
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
//读取请求数据
|
||||||
|
size, err := conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
//回写相应数据
|
||||||
|
conn.Write(buf[:size])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
利用协程实现同步
|
||||||
|
|
||||||
|
其实协程并不仅限于实现Thread-Per-Message模式,它还可以将异步模式转换为同步模式。异步编程虽然近几年取得了长足发展,但是异步的思维模式对于普通人来讲毕竟是有难度的,只有线性的思维模式才是适合所有人的。而线性的思维模式反映到编程世界,就是同步。
|
||||||
|
|
||||||
|
在Java里使用多线程并发地处理I/O,基本上用的都是异步非阻塞模型,这种模型的异步主要是靠注册回调函数实现的,那能否都使用同步处理呢?显然是不能的。因为同步意味着等待,而线程等待,本质上就是一种严重的浪费。不过对于协程来说,等待的成本就没有那么高了,所以基于协程实现同步非阻塞是一个可行的方案。
|
||||||
|
|
||||||
|
OpenResty里实现的cosocket就是一种同步非阻塞方案,借助cosocket我们可以用线性的思维模式来编写非阻塞的程序。下面的示例代码是用cosocket实现的socket程序的客户端,建立连接、发送请求、读取响应所有的操作都是同步的,由于cosocket本身是非阻塞的,所以这些操作虽然是同步的,但是并不会阻塞。
|
||||||
|
|
||||||
|
-- 创建socket
|
||||||
|
local sock = ngx.socket.tcp()
|
||||||
|
-- 设置socket超时时间
|
||||||
|
sock:settimeouts(connect_timeout, send_timeout, read_timeout)
|
||||||
|
-- 连接到目标地址
|
||||||
|
local ok, err = sock:connect(host, port)
|
||||||
|
if not ok then
|
||||||
|
- -- 省略异常处理
|
||||||
|
end
|
||||||
|
-- 发送请求
|
||||||
|
local bytes, err = sock:send(request_data)
|
||||||
|
if not bytes then
|
||||||
|
-- 省略异常处理
|
||||||
|
end
|
||||||
|
-- 读取响应
|
||||||
|
local line, err = sock:receive()
|
||||||
|
if err then
|
||||||
|
-- 省略异常处理
|
||||||
|
end
|
||||||
|
-- 关闭socket
|
||||||
|
sock:close()
|
||||||
|
-- 处理读取到的数据line
|
||||||
|
handle(line)
|
||||||
|
|
||||||
|
|
||||||
|
结构化并发编程
|
||||||
|
|
||||||
|
Golang中的 go 语句让协程用起来太简单了,但是这种简单也蕴藏着风险。要深入了解这个风险是什么,就需要先了解一下 goto 语句的前世今生。
|
||||||
|
|
||||||
|
在我上学的时候,各种各样的编程语言书籍中都会谈到不建议使用 goto 语句,原因是 goto 语句会让程序变得混乱,当时对于这个问题我也没有多想,不建议用那就不用了。那为什么 goto 语句会让程序变得混乱呢?混乱具体指的又是什么呢?多年之后,我才了解到所谓的混乱指的是代码的书写顺序和执行顺序不一致。代码的书写顺序,代表的是我们的思维过程,如果思维的过程与代码执行的顺序不一致,那就会干扰我们对代码的理解。我们的思维是线性的,傻傻地一条道儿跑到黑,而goto语句太灵活,随时可以穿越时空,实在是太“混乱”了。
|
||||||
|
|
||||||
|
首先发现 goto 语句是“毒药”的人是著名的计算机科学家艾兹格·迪科斯彻(Edsger Dijkstra),同时他还提出了结构化程序设计。在结构化程序设计中,可以使用三种基本控制结构来代替goto,这三种基本的控制结构就是今天我们广泛使用的顺序结构、选择结构和循环结构。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
顺序结构
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
选择结构
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
循环结构(while)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
循环结构(do while)
|
||||||
|
|
||||||
|
这三种基本的控制结构奠定了今天高级语言的基础,如果仔细观察这三种结构,你会发现它们的入口和出口只有一个,这意味它们是可组合的,而且组合起来一定是线性的,整体来看,代码的书写顺序和执行顺序也是一致的。
|
||||||
|
|
||||||
|
我们以前写的并发程序,是否违背了结构化程序设计呢?这个问题以前并没有被关注,但是最近两年,随着并发编程的快速发展,已经开始有人关注了,而且剑指Golang中的 go 语句,指其为“毒药”,类比的是 goto 语句。详情可以参考相关的文章。
|
||||||
|
|
||||||
|
Golang中的 go 语句不过是快速创建协程的方法而已,这篇文章本质上并不仅仅在批判Golang中的 go 语句,而是在批判开启新的线程(或者协程)异步执行这种粗糙的做法,违背了结构化程序设计,Java语言其实也在其列。
|
||||||
|
|
||||||
|
当开启一个新的线程时,程序会并行地出现两个分支,主线程一个分支,子线程一个分支,这两个分支很多情况下都是天各一方、永不相见。而结构化的程序,可以有分支,但是最终一定要汇聚,不能有多个出口,因为只有这样它们组合起来才是线性的。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
最近几年支持协程的开发语言越来越多了,Java OpenSDK中Loom项目的目标就是支持协程,相信不久的将来,Java程序员也可以使用协程来解决并发问题了。
|
||||||
|
|
||||||
|
计算机里很多面向开发人员的技术,大多数都是在解决一个问题:易用性。协程作为一项并发编程技术,本质上也不过是解决并发工具的易用性问题而已。对于易用性,我觉得最重要的就是要适应我们的思维模式,在工作的前几年,我并没有怎么关注它,但是最近几年思维模式已成为我重点关注的对象。因为思维模式对工作的很多方面都会产生影响,例如质量。
|
||||||
|
|
||||||
|
一个软件产品是否能够活下去,从质量的角度看,最核心的就是代码写得好。那什么样的代码是好代码呢?我觉得,最根本的是可读性好。可读性好的代码,意味着大家都可以上手,而且上手后不会大动干戈。那如何让代码的可读性好呢?很简单,换位思考,用大众、普通的思维模式去写代码,而不是炫耀自己的各种设计能力。我觉得好的代码,就像人民的艺术一样,应该是为人民群众服务的,只有根植于广大群众之中,才有生命力。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
133
专栏/Java并发编程实战/45CSP模型:Golang的主力队员.md
Normal file
133
专栏/Java并发编程实战/45CSP模型:Golang的主力队员.md
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
45 CSP模型:Golang的主力队员
|
||||||
|
Golang是一门号称从语言层面支持并发的编程语言,支持并发是Golang一个非常重要的特性。在上一篇文章《44 | 协程:更轻量级的线程》中我们介绍过,Golang支持协程,协程可以类比Java中的线程,解决并发问题的难点就在于线程(协程)之间的协作。
|
||||||
|
|
||||||
|
那Golang是如何解决协作问题的呢?
|
||||||
|
|
||||||
|
总的来说,Golang提供了两种不同的方案:一种方案支持协程之间以共享内存的方式通信,Golang提供了管程和原子类来对协程进行同步控制,这个方案与Java语言类似;另一种方案支持协程之间以消息传递(Message-Passing)的方式通信,本质上是要避免共享,Golang的这个方案是基于CSP(Communicating Sequential Processes)模型实现的。Golang比较推荐的方案是后者。
|
||||||
|
|
||||||
|
什么是CSP模型
|
||||||
|
|
||||||
|
我们在《42 | Actor模型:面向对象原生的并发模型》中介绍了Actor模型,Actor模型中Actor之间就是不能共享内存的,彼此之间通信只能依靠消息传递的方式。Golang实现的CSP模型和Actor模型看上去非常相似,Golang程序员中有句格言:“不要以共享内存方式通信,要以通信方式共享内存(Don’t communicate by sharing memory, share memory by communicating)。”虽然Golang中协程之间,也能够以共享内存的方式通信,但是并不推荐;而推荐的以通信的方式共享内存,实际上指的就是协程之间以消息传递方式来通信。
|
||||||
|
|
||||||
|
下面我们先结合一个简单的示例,看看Golang中协程之间是如何以消息传递的方式实现通信的。我们示例的目标是打印从1累加到100亿的结果,如果使用单个协程来计算,大概需要4秒多的时间。单个协程,只能用到CPU中的一个核,为了提高计算性能,我们可以用多个协程来并行计算,这样就能发挥多核的优势了。
|
||||||
|
|
||||||
|
在下面的示例代码中,我们用了4个子协程来并行执行,这4个子协程分别计算[1, 25亿]、(25亿, 50亿]、(50亿, 75亿]、(75亿, 100亿],最后再在主协程中汇总4个子协程的计算结果。主协程要汇总4个子协程的计算结果,势必要和4个子协程之间通信,Golang中协程之间通信推荐的是使用channel,channel你可以形象地理解为现实世界里的管道。另外,calc()方法的返回值是一个只能接收数据的channel ch,它创建的子协程会把计算结果发送到这个ch中,而主协程也会将这个计算结果通过ch读取出来。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 变量声明
|
||||||
|
var result, i uint64
|
||||||
|
// 单个协程执行累加操作
|
||||||
|
start := time.Now()
|
||||||
|
for i = 1; i <= 10000000000; i++ {
|
||||||
|
result += i
|
||||||
|
}
|
||||||
|
// 统计计算耗时
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
fmt.Printf("执行消耗的时间为:", elapsed)
|
||||||
|
fmt.Println(", result:", result)
|
||||||
|
|
||||||
|
// 4个协程共同执行累加操作
|
||||||
|
start = time.Now()
|
||||||
|
ch1 := calc(1, 2500000000)
|
||||||
|
ch2 := calc(2500000001, 5000000000)
|
||||||
|
ch3 := calc(5000000001, 7500000000)
|
||||||
|
ch4 := calc(7500000001, 10000000000)
|
||||||
|
// 汇总4个协程的累加结果
|
||||||
|
result = <-ch1 + <-ch2 + <-ch3 + <-ch4
|
||||||
|
// 统计计算耗时
|
||||||
|
elapsed = time.Since(start)
|
||||||
|
fmt.Printf("执行消耗的时间为:", elapsed)
|
||||||
|
fmt.Println(", result:", result)
|
||||||
|
}
|
||||||
|
// 在协程中异步执行累加操作,累加结果通过channel传递
|
||||||
|
func calc(from uint64, to uint64) <-chan uint64 {
|
||||||
|
// channel用于协程间的通信
|
||||||
|
ch := make(chan uint64)
|
||||||
|
// 在协程中执行累加操作
|
||||||
|
go func() {
|
||||||
|
result := from
|
||||||
|
for i := from + 1; i <= to; i++ {
|
||||||
|
result += i
|
||||||
|
}
|
||||||
|
// 将结果写入channel
|
||||||
|
ch <- result
|
||||||
|
}()
|
||||||
|
// 返回结果是用于通信的channel
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CSP模型与生产者-消费者模式
|
||||||
|
|
||||||
|
你可以简单地把Golang实现的CSP模型类比为生产者-消费者模式,而channel可以类比为生产者-消费者模式中的阻塞队列。不过,需要注意的是Golang中channel的容量可以是0,容量为0的channel在Golang中被称为无缓冲的channel,容量大于0的则被称为有缓冲的channel。
|
||||||
|
|
||||||
|
无缓冲的channel类似于Java中提供的SynchronousQueue,主要用途是在两个协程之间做数据交换。比如上面累加器的示例代码中,calc()方法内部创建的channel就是无缓冲的channel。
|
||||||
|
|
||||||
|
而创建一个有缓冲的channel也很简单,在下面的示例代码中,我们创建了一个容量为4的channel,同时创建了4个协程作为生产者、4个协程作为消费者。
|
||||||
|
|
||||||
|
// 创建一个容量为4的channel
|
||||||
|
ch := make(chan int, 4)
|
||||||
|
// 创建4个协程,作为生产者
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
go func() {
|
||||||
|
ch <- 7
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// 创建4个协程,作为消费者
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
go func() {
|
||||||
|
o := <-ch
|
||||||
|
fmt.Println("received:", o)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Golang中的channel是语言层面支持的,所以可以使用一个左向箭头(<-)来完成向channel发送数据和读取数据的任务,使用上还是比较简单的。Golang中的channel是支持双向传输的,所谓双向传输,指的是一个协程既可以通过它发送数据,也可以通过它接收数据。
|
||||||
|
|
||||||
|
不仅如此,Golang中还可以将一个双向的channel变成一个单向的channel,在累加器的例子中,calc()方法中创建了一个双向channel,但是返回的就是一个只能接收数据的单向channel,所以主协程中只能通过它接收数据,而不能通过它发送数据,如果试图通过它发送数据,编译器会提示错误。对比之下,双向变单向的功能,如果以SDK方式实现,还是很困难的。
|
||||||
|
|
||||||
|
CSP模型与Actor模型的区别
|
||||||
|
|
||||||
|
同样是以消息传递的方式来避免共享,那Golang实现的CSP模型和Actor模型有什么区别呢?
|
||||||
|
|
||||||
|
第一个最明显的区别就是:Actor模型中没有channel。虽然Actor模型中的 mailbox 和 channel 非常像,看上去都像个FIFO队列,但是区别还是很大的。Actor模型中的mailbox对于程序员来说是“透明”的,mailbox明确归属于一个特定的Actor,是Actor模型中的内部机制;而且Actor之间是可以直接通信的,不需要通信中介。但CSP模型中的 channel 就不一样了,它对于程序员来说是“可见”的,是通信的中介,传递的消息都是直接发送到 channel 中的。
|
||||||
|
|
||||||
|
第二个区别是:Actor模型中发送消息是非阻塞的,而CSP模型中是阻塞的。Golang实现的CSP模型,channel是一个阻塞队列,当阻塞队列已满的时候,向channel中发送数据,会导致发送消息的协程阻塞。
|
||||||
|
|
||||||
|
第三个区别则是关于消息送达的。在《42 | Actor模型:面向对象原生的并发模型》这篇文章中,我们介绍过Actor模型理论上不保证消息百分百送达,而在Golang实现的CSP模型中,是能保证消息百分百送达的。不过这种百分百送达也是有代价的,那就是有可能会导致死锁。
|
||||||
|
|
||||||
|
比如,下面这段代码就存在死锁问题,在主协程中,我们创建了一个无缓冲的channel ch,然后从ch中接收数据,此时主协程阻塞,main()方法中的主协程阻塞,整个应用就阻塞了。这就是Golang中最简单的一种死锁。
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 创建一个无缓冲的channel
|
||||||
|
ch := make(chan int)
|
||||||
|
// 主协程会阻塞在此处,发生死锁
|
||||||
|
<- ch
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
Golang中虽然也支持传统的共享内存的协程间通信方式,但是推荐的还是使用CSP模型,以通信的方式共享内存。
|
||||||
|
|
||||||
|
Golang中实现的CSP模型功能上还是很丰富的,例如支持select语句,select语句类似于网络编程里的多路复用函数select(),只要有一个channel能够发送成功或者接收到数据就可以跳出阻塞状态。鉴于篇幅原因,我就点到这里,不详细介绍那么多了。
|
||||||
|
|
||||||
|
CSP模型是托尼·霍尔(Tony Hoare)在1978年提出的,不过这个模型这些年一直都在发展,其理论远比Golang的实现复杂得多,如果你感兴趣,可以参考霍尔写的Communicating Sequential Processes这本电子书。另外,霍尔在并发领域还有一项重要成就,那就是提出了霍尔管程模型,这个你应该很熟悉了,Java领域解决并发问题的理论基础就是它。
|
||||||
|
|
||||||
|
Java领域可以借助第三方的类库JCSP来支持CSP模型,相比Golang的实现,JCSP更接近理论模型,如果你感兴趣,可以下载学习。不过需要注意的是,JCSP并没有经过广泛的生产环境检验,所以并不建议你在生产环境中使用。
|
||||||
|
|
||||||
|
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
125
专栏/Java并发编程实战/用户来信真好,面试考到这些并发编程,我都答对了!.md
Normal file
125
专栏/Java并发编程实战/用户来信真好,面试考到这些并发编程,我都答对了!.md
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
用户来信 真好,面试考到这些并发编程,我都答对了!
|
||||||
|
|
||||||
|
|
||||||
|
你好,我是Zed,是《Java并发编程实战》1W+订阅者中的一员。
|
||||||
|
|
||||||
|
我从事Java开发已有五年时间了,曾在一家国内知名物流企业工作,现在杭州一家金融支付类公司继续担任Java工程师一职。
|
||||||
|
|
||||||
|
大概在今年四月份,在高铁上翻到一篇文章,讲的是“为什么Object.wait()方法一定要在synchronized内部使用”,因为之前我根本不知道这个问题,所以打算考考我朋友。
|
||||||
|
|
||||||
|
结果他给了我一些迥然不同的答案,并邀请我读了宝令老师的《Java并发编程实战》专栏中的一篇文章《08 | 管程:并发编程的万能钥匙》,看完后我感觉醍醐灌顶,津津有味,果断开始学习。
|
||||||
|
|
||||||
|
我是如何通过专栏拿到 Offer 的?
|
||||||
|
|
||||||
|
机缘巧合,专栏学习到一半时 ,我辞掉了原有的工作出去面试。因为面试的岗位都是高级工程师,所以基本上离不开并发编程的问题,像锁、线程安全、线程池、并发工具类都是家常便饭。
|
||||||
|
|
||||||
|
印象比较深刻的是面试官问我:线程池的大小如何确定?
|
||||||
|
|
||||||
|
那时我刚看完《10 | Java线程(中):创建多少线程才是合适的?》,然后就胸有成竹且不紧不慢地回答了,面试官听了直点头。
|
||||||
|
|
||||||
|
另外一个问题是:怎么理解活锁?
|
||||||
|
|
||||||
|
于是,我又如法炮制搬出了《07 | 安全性、活跃性以及性能问题》中老师提到“路人甲乙相撞”的例子,同时给出具体的解决方案。
|
||||||
|
|
||||||
|
不得不说,这个例子太经典了,这里我必须再展示给大家看!在文章里老师是这样描述活锁的:
|
||||||
|
|
||||||
|
|
||||||
|
所谓的“活锁”,可以类比现实世界里的例子。路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。
|
||||||
|
|
||||||
|
这种情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。
|
||||||
|
|
||||||
|
|
||||||
|
并且给出了简单有效的解决方案:
|
||||||
|
|
||||||
|
|
||||||
|
解决“活锁”的方案很简单:谦让时,尝试等待一个随机的时间就可以了。
|
||||||
|
|
||||||
|
例如上面的那个例子,路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。
|
||||||
|
|
||||||
|
“等待一个随机时间”的方案虽然很简单,却非常有效,Raft这样知名的分布式一致性算法中也用到了它。
|
||||||
|
|
||||||
|
|
||||||
|
真的很庆幸提前遇到了并发专栏,我的面试顺利通过了。
|
||||||
|
|
||||||
|
我可以很负责任地说,如果没有专栏的学习,我不会那么顺利地找到工作。换句话说,专栏其实涵盖了几乎所有大家面试可能会被问到的内容。
|
||||||
|
|
||||||
|
我是如何进行高效学习的呢?
|
||||||
|
|
||||||
|
第一,直接上手跟着敲一遍代码。
|
||||||
|
|
||||||
|
我觉得最能表示你在用心学习的方式就是付诸实际行动了,就拿专栏第一模块“并发理论基础”来讲,会涉及到很多的例子,比如:
|
||||||
|
|
||||||
|
|
||||||
|
可见性、原子性等问题的举例说明,直接看《01 | 可见性、原子性和有序性问题:并发编程Bug的源头》;
|
||||||
|
用“银行转账模拟”的例子引出死锁问题并如何处理,《05 | 一不小心就死锁了,怎么办?》;
|
||||||
|
如何保证线程安全的同时保证性能,《06 | 用“等待-通知”机制优化循环等待》。
|
||||||
|
……
|
||||||
|
|
||||||
|
|
||||||
|
针对这些例子,我能做的就是自己花时间,手动敲一遍代码。要知道,纸上得来终觉浅,绝知此事要躬行!
|
||||||
|
|
||||||
|
第二,换位理解。
|
||||||
|
|
||||||
|
这也是我觉得最为有效的学习方式之一,站在老师角度,去思考他是如何看这个问题的,他是如何一步步讲解清楚的。而且,宝令老师在每篇文章后面,都会有一段总结,非常有效地来帮助我去获得这篇文章的知识点。提出问题—解决问题—总结得出结论,这关键的三步,在任何地方都适用。
|
||||||
|
|
||||||
|
所以,我还会花时间思考专栏的“这里”或“那里”跟我之前的理解是否有出入,例如《11 | Java线程(下):为什么局部变量是线程安全的?》中对局部变量线程安全的解释,跟自己了解的虚拟机执行过程变量共享关系是否吻合?
|
||||||
|
|
||||||
|
反复问自己问题的答案是什么,然后和老师的理解做对比,收获感和进步才会是巨大的。
|
||||||
|
|
||||||
|
第三,坚持,坚持,再坚持。
|
||||||
|
|
||||||
|
学习最难的也是最有价值的一点就是“坚持”,所以每天我都会主动去看专栏,包括相关书籍以及网上的各类资料。学习到新的知识是一件多么幸福的事情,每天一点点,这日积月累下来就是一笔不小的财富。我们办公室就有一个有趣的墙画,内容就是:
|
||||||
|
|
||||||
|
1.01365\=37.8;
|
||||||
|
|
||||||
|
而1.02365\=1 377.4。
|
||||||
|
|
||||||
|
每天跟着专栏去渗透,每一句话都读得很细致,每次看到后一句忘记前面说的都会返回去再读一遍直到弄懂为止。知识一定要彻底掌握才能被更好地使用,这是我个人的要求。需要补充一句,学习的时候切记要结合源码去看,事半功倍!
|
||||||
|
|
||||||
|
再说专栏的 2 个宝藏之地
|
||||||
|
|
||||||
|
除此之外,还有两个我很喜欢的、也很激励我学习的点。
|
||||||
|
|
||||||
|
第一个,每一篇文章最后都会有思考题,而思考题的背后就是众多同学的头脑风暴。
|
||||||
|
|
||||||
|
每篇文章的留言我都会细细去看,看同学们的回答以及提问我是否了解,如果了解,就暗暗地“得意”一番;如果没有,那就说明我还没完全弄明白,路漫漫仍需继续努力。
|
||||||
|
|
||||||
|
所以,留言区也是一个宝藏之地,有些同学的回答甚至可能比老师举的例子更加让人印象深刻,例如《01 | 可见性、原子性和有序性问题:并发编程Bug的源头》下的留言,两个字,精彩!当初读完第一章的时候我就暗下决心,绝不能输给这些同学。
|
||||||
|
|
||||||
|
这也侧面反映了一点:自己拿一本书去看去学,和你潜意识里知道有很多人在跟你一起学,效果是完全不一样的。-
|
||||||
|
|
||||||
|
|
||||||
|
第二个,热点问题答疑,这也是宝令老师专栏的特色了。
|
||||||
|
|
||||||
|
每一模块的最后都会专门有一篇文章去详细回答各类问题,看这类问题的同时我自己脑子里存储的各类知识也都会融会贯通起来,不知不觉中勾勒出自己的知识全景图,很有成就感。
|
||||||
|
|
||||||
|
总结
|
||||||
|
|
||||||
|
平心而论,如果不是因为这个专栏,我想我不会学得这么快、学得这么好、中间找工作也找得那么顺利。当初也是被宝令老师的知识总结以及传授方式所吸引,才选择去订阅,看完专栏第一个模块就的确感觉学了很多很多,收获颇丰。
|
||||||
|
|
||||||
|
所以,我可以拍胸脯说:这是我订阅的所有专栏里最值的一个!
|
||||||
|
|
||||||
|
在结束语中,宝令老师说他自己好为人师,作为读者的我确实感受到了。专栏下面的留言,老师都会耐心去解答,看回复也能收获很多。后续也希望老师能继续输出一些自己的所得和见解,而我们,则站在巨人的肩膀上,遇见我们最美好的风景!
|
||||||
|
|
||||||
|
在最后,我也附上我自己学习过程中的一些代码积累。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
宝令老师的回信:
|
||||||
|
|
||||||
|
首先,恭喜 Zed 顺利收获一份新工作。我觉得能通过面试只有一个原因就是技术水平过关,并发相关的知识和技术到位,面试自然就轻松了。
|
||||||
|
|
||||||
|
感谢你分享的学习方法,尤其是第一点:直接上手跟着敲一遍代码。我们搞工程的必须上手才能理解的更深刻,所以专栏一直都没有给出一份完整的代码,更希望大家自己去动手。
|
||||||
|
|
||||||
|
大致浏览了一下你在 Github 分享的代码以及知识点的总结,代码写的很规范,能看出来很用心,我觉得你的新东家眼光很不错!
|
||||||
|
|
||||||
|
非常感谢 Zed 及大家,能坚持看完我的专栏,并能运用在实际工作中,真的值了。也欢迎大家把这篇文章分享给朋友,相互学习,互相激励。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
25
专栏/Java并发编程实战/结束语十年之后,初心依旧.md
Normal file
25
专栏/Java并发编程实战/结束语十年之后,初心依旧.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
结束语 十年之后,初心依旧
|
||||||
|
曾经有个特别好的朋友跟我说过:“你挺适合当老师的!”其实适不适合并不一定,但是好为人师是一定的。到这里,我已经分享了45篇的技术文章,估计你也看累了、听累了,需要些时间好好消化消化。所以,最后咱们轻松一下吧,聊聊人生、聊聊理想,正好我也和你聊聊我那些“不堪回首的往事”。
|
||||||
|
|
||||||
|
我曾经搞过5年的ERP,其间我是很想在这条路上一直走下去,但在这个行业摸爬滚打了几年之后,我发现这个行业里懂业务比懂技术更重要。于是为了提高业务水平,我就去搞注册会计师了;但在我还没有搞定它的时候,我突然发现自己竟然失业了。这个时候我才意识到,选择拼搏于细分行业里的夕阳产业,是多么愚蠢。选择,永远比努力更重要。
|
||||||
|
|
||||||
|
可笑的是我们选择的,往往不是我们期望的那样。后来我阴错阳差去了一家央企,传统观点认为这里和养老院是对门儿,可实际上,在“养老院对门儿”的这三年多,是我成长最快的三年,包括技术。这三年属于被“骂”的最多的三年,做的东西被同行“骂”,汇报被领导“骂”,被“骂”的多了,渐渐就意识到自己的问题了。找到自己的问题,才是最重要的。
|
||||||
|
|
||||||
|
一哥们儿曾有过一段经典的总结:所有的失败都可以归结为“错估了形势,低估了敌人,高估了自己”。人,总是高估了自己,显然,我也是。很多时候,我也会一不小心就高估了自己,而且还一点都意识不到。感谢佛家经典《金刚经》,虽说到现在我也没有把它抄完,但是抄到不到一半的时候,我已经深深认识到自己是多么的浅薄与狂妄了。驱除虚妄,才能进步。
|
||||||
|
|
||||||
|
搞技术的瓶颈在哪里呢?每个人资质、机遇不同,其实没有必要强求。我也曾经兴趣广泛,大学时还买过全英文的《Intel微处理器》,搬了几次家,都没舍得扔,前两年终于扔掉了,纯粹是浪费时间和空间。有时我们得承认,不是随便一个领域我们都能干得很深入的,实际场景和资质都很重要。拿不动的东西越早放弃越好,做了减法,才能做加法,生也有涯,该放就放。
|
||||||
|
|
||||||
|
工作十年,很多人已经在不同的轨道上了,有些人选择了做管理,有些人选择了创业,只有很少的人在搞技术。十年,很多面具下的脸都已千疮百孔,有些人摘下面具很丑,有些人摘下面具很怪,只有很少的人摘下面具你还认得。事实证明,你不认得的,基本都已落马;你还认得的,基本都混得不错。正所谓,路遥知马力,日久见人心。简单做人,挺好。
|
||||||
|
|
||||||
|
工作了十多年,最值得骄傲的是,更加相信善良。最后也祝你十年之后,初心依旧。
|
||||||
|
|
||||||
|
](https://jinshuju.net/f/9W7ghF)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
67
专栏/Java核心技术面试精讲/00开篇词以面试题为切入点,有效提升你的Java内功.md
Normal file
67
专栏/Java核心技术面试精讲/00开篇词以面试题为切入点,有效提升你的Java内功.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
00 开篇词 以面试题为切入点,有效提升你的Java内功
|
||||||
|
Java是一门历史悠久的编程语言,可以毫无争议地说,Java是最主流的编程语言之一。全球有1200万以上Java程序员以及海量的设备,还有无所不能的Java生态圈。
|
||||||
|
|
||||||
|
我所知道的诸如阿里巴巴、京东、百度、腾讯、美团、去哪儿等互联网公司,基本都是以Java为首要编程语言的。即使在最新的云计算领域,Java仍然是AWS、Google App Engine等平台上,使用最多的编程语言;甚至是微软Azure云上,Java也以微弱劣势排在前三位。所以,在这些大公司的面试中,基本都会以Java为切入点,考评一个面试者的技术能力。
|
||||||
|
|
||||||
|
应聘初级、中级Java工程师,通常只要求扎实的Java和计算机科学基础,掌握主流开源框架的使用;Java高级工程师或者技术专家,则往往全面考察Java IO/NIO、并发、虚拟机等,不仅仅是了解,更要求对底层源代码层面的掌握,并对分布式、安全、性能等领域能力有进一步的要求。
|
||||||
|
|
||||||
|
我在Oracle已经工作了近7年,负责过北京Java核心类库、国际化、分发服务等技术团队的组建,面试过从初级到非常资深的Java开发工程师。由于Java组工作任务的特点,我非常注重面试者的计算机科学基础和编程语言的理解深度,我甚至不要求面试者非要精通Java,如果对C/C++等其他语言能够掌握得非常系统和深入,也是符合需求的。
|
||||||
|
|
||||||
|
工作多年以及在面试中,我经常能体会到,有些面试者确实是认真努力工作,但坦白说表现出的能力水平却不足以通过面试,通常是两方面原因:
|
||||||
|
|
||||||
|
|
||||||
|
“知其然不知其所以然”。做了多年技术,开发了很多业务应用,但似乎并未思考过种种技术选择背后的逻辑。坦白说,我并不放心把具有一定深度的任务交给他。更重要的是,我并不确定他未来技术能力的成长潜力有多大。团队所从事的是公司核心产品,工作于基础技术领域,我们不需要那些“差不多”或“还行”的代码,而是需要达到一定水准的高质量设计与实现。我相信很多其他技术团队的要求会更多、更高。
|
||||||
|
|
||||||
|
知识碎片化,不成系统。在面试中,面试者似乎无法完整、清晰地描述自己所开发的系统,或者使用的相关技术。平时可能埋头苦干,或者过于死磕某个实现细节,并没有抬头审视这些技术。比如,有的面试者,有一些并发编程经验,但对基本的并发类库掌握却并不扎实,似乎觉得在用的时候进行“面向搜索引擎的编程”就足够了。这种情况下,我没有信心这个面试者有高效解决复杂问题、设计复杂系统的能力。
|
||||||
|
|
||||||
|
|
||||||
|
前人已经掉过的坑,后来的同学就别再“前仆后继”了!
|
||||||
|
|
||||||
|
起初,极客时间邀请我写《Java核心技术面试精讲》专栏,我一开始心里是怀疑其形式和必要性的。经典的书籍一大堆呀,网上也能搜到所谓的“面试宝典”呀,为什么还需要我“指手画脚”?
|
||||||
|
|
||||||
|
但随着深入交流,我逐渐被说服了。我发现很多面试者其实是很努力的,只是
|
||||||
|
|
||||||
|
|
||||||
|
很难甑别出各种技术的核心与要点,技术书籍这么庞杂,对于经验有限的同学,找到高效归纳自己知识体系的方法并不容易。
|
||||||
|
|
||||||
|
各种“宝典”更专注于问题,解答大多点到即止,甚至有些解答准确性都值得商榷,缺乏系统性的分析与举一反三的讲解。
|
||||||
|
|
||||||
|
|
||||||
|
我在极客时间推出这个专栏,就是为了让更多没有经验或者经验有限的开发者,在准备面试时:
|
||||||
|
|
||||||
|
|
||||||
|
少走弯路,利用有限的精力,能够更加高效地准备和学习。
|
||||||
|
|
||||||
|
提纲挈领,在知识点讲解的同时,为你梳理一个相对完整的Java开发技术能力图谱,将基础夯实。
|
||||||
|
|
||||||
|
|
||||||
|
Java面试题目千奇百怪,有的面试官甚至会以黑魔法一样的态度,刨根问底JVM底层,似乎不深挖JVM源代码、不谈谈计算机指令,就是不爱学习,这是仁者见仁智者见智的事儿。我会根据自己的经验,围绕Java开发技术的方方面面,精选出5大模块,共36道题目,给出典型的回答,并层层深入剖析。
|
||||||
|
|
||||||
|
5大模块分为:
|
||||||
|
|
||||||
|
|
||||||
|
Java基础:我会围绕Java语言基本特性和机制,由点带面,让你构建牢固的Java技术功底。
|
||||||
|
|
||||||
|
Java进阶:将围绕并发编程、Java虚拟机等领域展开,助你攻坚大厂Java面试的核心阵地。
|
||||||
|
|
||||||
|
Java应用开发扩展:从数据库编程、主流开源框架、分布式开发等,帮你掌握Java开发的十八般兵器。
|
||||||
|
|
||||||
|
Java安全基础:让你理解常见的应用安全问题和处理方法,掌握如何写出符合大厂规范的安全代码。
|
||||||
|
|
||||||
|
Java性能基础:你将掌握相关工具、方法论与基础实践。
|
||||||
|
|
||||||
|
|
||||||
|
这几年我从业务系统或产品开发,切换到Java平台自身,接触了更多Java领域的核心技术,我相信我的分享能够提供一些独到的内容,而不是简单的人云亦云。
|
||||||
|
|
||||||
|
时移世易,很多大家耳熟能知的问题,其实在现代Java里已经发生了根本性的改变。在技术领域,即使你打算或已经转为技术管理等,扎实的技术功底也是必须的。希望通过我的专栏,不仅可以让你面试成功,还能帮助你未来职业发展更进一步。
|
||||||
|
|
||||||
|
万丈高楼平地起,愿我这个Java老兵,能与你一道,逐个击破大厂Java面试考点,直击Java技术核心要点,构建你的Java知识体系。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
164
专栏/Java核心技术面试精讲/02Exception和Error有什么区别?.md
Normal file
164
专栏/Java核心技术面试精讲/02Exception和Error有什么区别?.md
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
02 Exception和Error有什么区别?
|
||||||
|
世界上存在永远不会出错的程序吗?也许这只会出现在程序员的梦中。随着编程语言和软件的诞生,异常情况就如影随形地纠缠着我们,只有正确处理好意外情况,才能保证程序的可靠性。
|
||||||
|
|
||||||
|
Java语言在设计之初就提供了相对完善的异常处理机制,这也是Java得以大行其道的原因之一,因为这种机制大大降低了编写和维护可靠程序的门槛。如今,异常处理机制已经成为现代编程语言的标配。
|
||||||
|
|
||||||
|
今天我要问你的问题是,请对比Exception和Error,另外,运行时异常与一般异常有什么区别?
|
||||||
|
|
||||||
|
典型回答
|
||||||
|
|
||||||
|
Exception和Error都是继承了Throwable类,在Java中只有Throwable类型的实例才可以被抛出(throw)或者捕获(catch),它是异常处理机制的基本组成类型。
|
||||||
|
|
||||||
|
Exception和Error体现了Java平台设计者对不同异常情况的分类。Exception是程序正常运行中,可以预料的意外情况,可能并且应该被捕获,进行相应处理。
|
||||||
|
|
||||||
|
Error是指在正常情况下,不大可能出现的情况,绝大部分的Error都会导致程序(比如JVM自身)处于非正常的、不可恢复状态。既然是非正常情况,所以不便于也不需要捕获,常见的比如OutOfMemoryError之类,都是Error的子类。
|
||||||
|
|
||||||
|
Exception又分为可检查(checked)异常和不检查(unchecked)异常,可检查异常在源代码里必须显式地进行捕获处理,这是编译期检查的一部分。前面我介绍的不可查的Error,是Throwable不是Exception。
|
||||||
|
|
||||||
|
不检查异常就是所谓的运行时异常,类似 NullPointerException、ArrayIndexOutOfBoundsException之类,通常是可以编码避免的逻辑错误,具体根据需要来判断是否需要捕获,并不会在编译期强制要求。
|
||||||
|
|
||||||
|
考点分析
|
||||||
|
|
||||||
|
分析Exception和Error的区别,是从概念角度考察了Java处理机制。总的来说,还处于理解的层面,面试者只要阐述清楚就好了。
|
||||||
|
|
||||||
|
我们在日常编程中,如何处理好异常是比较考验功底的,我觉得需要掌握两个方面。
|
||||||
|
|
||||||
|
第一,理解Throwable、Exception、Error的设计和分类。比如,掌握那些应用最为广泛的子类,以及如何自定义异常等。
|
||||||
|
|
||||||
|
很多面试官会进一步追问一些细节,比如,你了解哪些Error、Exception或者RuntimeException?我画了一个简单的类图,并列出来典型例子,可以给你作为参考,至少做到基本心里有数。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
其中有些子类型,最好重点理解一下,比如NoClassDefFoundError和ClassNotFoundException有什么区别,这也是个经典的入门题目。
|
||||||
|
|
||||||
|
第二,理解Java语言中操作Throwable的元素和实践。掌握最基本的语法是必须的,如try-catch-finally块,throw、throws关键字等。与此同时,也要懂得如何处理典型场景。
|
||||||
|
|
||||||
|
异常处理代码比较繁琐,比如我们需要写很多千篇一律的捕获代码,或者在finally里面做一些资源回收工作。随着Java语言的发展,引入了一些更加便利的特性,比如try-with-resources和multiple catch,具体可以参考下面的代码段。在编译时期,会自动生成相应的处理逻辑,比如,自动按照约定俗成close那些扩展了AutoCloseable或者Closeable的对象。
|
||||||
|
|
||||||
|
try (BufferedReader br = new BufferedReader(…);
|
||||||
|
BufferedWriter writer = new BufferedWriter(…)) {// Try-with-resources
|
||||||
|
// do something
|
||||||
|
catch ( IOException | XEception e) {// Multiple catch
|
||||||
|
// Handle it
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
知识扩展
|
||||||
|
|
||||||
|
前面谈的大多是概念性的东西,下面我来谈些实践中的选择,我会结合一些代码用例进行分析。
|
||||||
|
|
||||||
|
先开看第一个吧,下面的代码反映了异常处理中哪些不当之处?
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 业务代码
|
||||||
|
// …
|
||||||
|
Thread.sleep(1000L);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Ignore it
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这段代码虽然很短,但是已经违反了异常处理的两个基本原则。
|
||||||
|
|
||||||
|
第一,尽量不要捕获类似Exception这样的通用异常,而是应该捕获特定异常,在这里是Thread.sleep()抛出的InterruptedException。
|
||||||
|
|
||||||
|
这是因为在日常的开发和合作中,我们读代码的机会往往超过写代码,软件工程是门协作的艺术,所以我们有义务让自己的代码能够直观地体现出尽量多的信息,而泛泛的Exception之类,恰恰隐藏了我们的目的。另外,我们也要保证程序不会捕获到我们不希望捕获的异常。比如,你可能更希望RuntimeException被扩散出来,而不是被捕获。
|
||||||
|
|
||||||
|
进一步讲,除非深思熟虑了,否则不要捕获Throwable或者Error,这样很难保证我们能够正确程序处理OutOfMemoryError。
|
||||||
|
|
||||||
|
第二,不要生吞(swallow)异常。这是异常处理中要特别注意的事情,因为很可能会导致非常难以诊断的诡异情况。
|
||||||
|
|
||||||
|
生吞异常,往往是基于假设这段代码可能不会发生,或者感觉忽略异常是无所谓的,但是千万不要在产品代码做这种假设!
|
||||||
|
|
||||||
|
如果我们不把异常抛出来,或者也没有输出到日志(Logger)之类,程序可能在后续代码以不可控的方式结束。没人能够轻易判断究竟是哪里抛出了异常,以及是什么原因产生了异常。
|
||||||
|
|
||||||
|
再来看看第二段代码
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 业务代码
|
||||||
|
// …
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这段代码作为一段实验代码,它是没有任何问题的,但是在产品代码中,通常都不允许这样处理。你先思考一下这是为什么呢?
|
||||||
|
|
||||||
|
我们先来看看printStackTrace()的文档,开头就是“Prints this throwable and its backtrace to the standard error stream”。问题就在这里,在稍微复杂一点的生产系统中,标准出错(STERR)不是个合适的输出选项,因为你很难判断出到底输出到哪里去了。
|
||||||
|
|
||||||
|
尤其是对于分布式系统,如果发生异常,但是无法找到堆栈轨迹(stacktrace),这纯属是为诊断设置障碍。所以,最好使用产品日志,详细地输出到日志系统里。
|
||||||
|
|
||||||
|
我们接下来看下面的代码段,体会一下Throw early, catch late原则。
|
||||||
|
|
||||||
|
public void readPreferences(String fileName){
|
||||||
|
//...perform operations...
|
||||||
|
InputStream in = new FileInputStream(fileName);
|
||||||
|
//...read the preferences file...
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
如果fileName是null,那么程序就会抛出NullPointerException,但是由于没有第一时间暴露出问题,堆栈信息可能非常令人费解,往往需要相对复杂的定位。这个NPE只是作为例子,实际产品代码中,可能是各种情况,比如获取配置失败之类的。在发现问题的时候,第一时间抛出,能够更加清晰地反映问题。
|
||||||
|
|
||||||
|
我们可以修改一下,让问题“throw early”,对应的异常信息就非常直观了。
|
||||||
|
|
||||||
|
public void readPreferences(String filename) {
|
||||||
|
Objects. requireNonNull(filename);
|
||||||
|
//...perform other operations...
|
||||||
|
InputStream in = new FileInputStream(filename);
|
||||||
|
//...read the preferences file...
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
至于“catch late”,其实是我们经常苦恼的问题,捕获异常后,需要怎么处理呢?最差的处理方式,就是我前面提到的“生吞异常”,本质上其实是掩盖问题。如果实在不知道如何处理,可以选择保留原有异常的cause信息,直接再抛出或者构建新的异常抛出去。在更高层面,因为有了清晰的(业务)逻辑,往往会更清楚合适的处理方式是什么。
|
||||||
|
|
||||||
|
有的时候,我们会根据需要自定义异常,这个时候除了保证提供足够的信息,还有两点需要考虑:
|
||||||
|
|
||||||
|
|
||||||
|
是否需要定义成Checked Exception,因为这种类型设计的初衷更是为了从异常情况恢复,作为异常设计者,我们往往有充足信息进行分类。
|
||||||
|
在保证诊断信息足够的同时,也要考虑避免包含敏感信息,因为那样可能导致潜在的安全问题。如果我们看Java的标准类库,你可能注意到类似java.net.ConnectException,出错信息是类似“ Connection refused (Connection refused)”,而不包含具体的机器名、IP、端口等,一个重要考量就是信息安全。类似的情况在日志中也有,比如,用户数据一般是不可以输出到日志里面的。
|
||||||
|
|
||||||
|
|
||||||
|
业界有一种争论(甚至可以算是某种程度的共识),Java语言的Checked Exception也许是个设计错误,反对者列举了几点:
|
||||||
|
|
||||||
|
|
||||||
|
Checked Exception的假设是我们捕获了异常,然后恢复程序。但是,其实我们大多数情况下,根本就不可能恢复。Checked Exception的使用,已经大大偏离了最初的设计目的。
|
||||||
|
|
||||||
|
Checked Exception不兼容functional编程,如果你写过Lambda/Stream代码,相信深有体会。
|
||||||
|
|
||||||
|
|
||||||
|
很多开源项目,已经采纳了这种实践,比如Spring、Hibernate等,甚至反映在新的编程语言设计中,比如Scala等。 如果有兴趣,你可以参考:
|
||||||
|
|
||||||
|
http://literatejava.com/exceptions/checked-exceptions-javas-biggest-mistake/。
|
||||||
|
|
||||||
|
当然,很多人也觉得没有必要矫枉过正,因为确实有一些异常,比如和环境相关的IO、网络等,其实是存在可恢复性的,而且Java已经通过业界的海量实践,证明了其构建高质量软件的能力。我就不再进一步解读了,感兴趣的同学可以点击链接,观看Bruce Eckel在2018年全球软件开发大会QCon的分享Failing at Failing: How and Why We’ve Been Nonchalantly Moving Away From Exception Handling。
|
||||||
|
|
||||||
|
我们从性能角度来审视一下Java的异常处理机制,这里有两个可能会相对昂贵的地方:
|
||||||
|
|
||||||
|
|
||||||
|
try-catch代码段会产生额外的性能开销,或者换个角度说,它往往会影响JVM对代码进行优化,所以建议仅捕获有必要的代码段,尽量不要一个大的try包住整段的代码;与此同时,利用异常控制代码流程,也不是一个好主意,远比我们通常意义上的条件语句(if/else、switch)要低效。
|
||||||
|
|
||||||
|
Java每实例化一个Exception,都会对当时的栈进行快照,这是一个相对比较重的操作。如果发生的非常频繁,这个开销可就不能被忽略了。
|
||||||
|
|
||||||
|
|
||||||
|
所以,对于部分追求极致性能的底层类库,有种方式是尝试创建不进行栈快照的Exception。这本身也存在争议,因为这样做的假设在于,我创建异常时知道未来是否需要堆栈。问题是,实际上可能吗?小范围或许可能,但是在大规模项目中,这么做可能不是个理智的选择。如果需要堆栈,但又没有收集这些信息,在复杂情况下,尤其是类似微服务这种分布式系统,这会大大增加诊断的难度。
|
||||||
|
|
||||||
|
当我们的服务出现反应变慢、吞吐量下降的时候,检查发生最频繁的Exception也是一种思路。关于诊断后台变慢的问题,我会在后面的Java性能基础模块中系统探讨。
|
||||||
|
|
||||||
|
今天,我从一个常见的异常处理概念问题,简单总结了Java异常处理的机制。并结合代码,分析了一些普遍认可的最佳实践,以及业界最新的一些异常使用共识。最后,我分析了异常性能开销,希望对你有所帮助。
|
||||||
|
|
||||||
|
一课一练
|
||||||
|
|
||||||
|
关于今天我们讨论的题目你做到心中有数了吗?可以思考一个问题,对于异常处理编程,不同的编程范式也会影响到异常处理策略,比如,现在非常火热的反应式编程(Reactive Stream),因为其本身是异步、基于事件机制的,所以出现异常情况,决不能简单抛出去;另外,由于代码堆栈不再是同步调用那种垂直的结构,这里的异常处理和日志需要更加小心,我们看到的往往是特定executor的堆栈,而不是业务方法调用关系。对于这种情况,你有什么好的办法吗?
|
||||||
|
|
||||||
|
请你在留言区分享一下你的解决方案,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||||
|
|
||||||
|
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
180
专栏/Java核心技术面试精讲/03谈谈final、finally、finalize有什么不同?.md
Normal file
180
专栏/Java核心技术面试精讲/03谈谈final、finally、finalize有什么不同?.md
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
03 谈谈final、finally、 finalize有什么不同?
|
||||||
|
Java语言有很多看起来很相似,但是用途却完全不同的语言要素,这些内容往往容易成为面试官考察你知识掌握程度的切入点。
|
||||||
|
|
||||||
|
今天,我要问你的是一个经典的Java基础题目,谈谈final、finally、 finalize有什么不同?
|
||||||
|
|
||||||
|
典型回答
|
||||||
|
|
||||||
|
final可以用来修饰类、方法、变量,分别有不同的意义,final修饰的class代表不可以继承扩展,final的变量是不可以修改的,而final的方法也是不可以重写的(override)。
|
||||||
|
|
||||||
|
finally则是Java保证重点代码一定要被执行的一种机制。我们可以使用try-finally或者try-catch-finally来进行类似关闭JDBC连接、保证unlock锁等动作。
|
||||||
|
|
||||||
|
finalize是基础类java.lang.Object的一个方法,它的设计目的是保证对象在被垃圾收集前完成特定资源的回收。finalize机制现在已经不推荐使用,并且在JDK 9开始被标记为deprecated。
|
||||||
|
|
||||||
|
考点分析
|
||||||
|
|
||||||
|
这是一个非常经典的Java基础问题,我上面的回答主要是从语法和使用实践角度出发的,其实还有很多方面可以深入探讨,面试官还可以考察你对性能、并发、对象生命周期或垃圾收集基本过程等方面的理解。
|
||||||
|
|
||||||
|
推荐使用final关键字来明确表示我们代码的语义、逻辑意图,这已经被证明在很多场景下是非常好的实践,比如:
|
||||||
|
|
||||||
|
|
||||||
|
我们可以将方法或者类声明为final,这样就可以明确告知别人,这些行为是不许修改的。
|
||||||
|
|
||||||
|
|
||||||
|
如果你关注过Java核心类库的定义或源码, 有没有发现java.lang包下面的很多类,相当一部分都被声明成为final class?在第三方类库的一些基础类中同样如此,这可以有效避免API使用者更改基础功能,某种程度上,这是保证平台安全的必要手段。
|
||||||
|
|
||||||
|
|
||||||
|
使用final修饰参数或者变量,也可以清楚地避免意外赋值导致的编程错误,甚至,有人明确推荐将所有方法参数、本地变量、成员变量声明成final。
|
||||||
|
|
||||||
|
final变量产生了某种程度的不可变(immutable)的效果,所以,可以用于保护只读数据,尤其是在并发编程中,因为明确地不能再赋值final变量,有利于减少额外的同步开销,也可以省去一些防御性拷贝的必要。
|
||||||
|
|
||||||
|
|
||||||
|
final也许会有性能的好处,很多文章或者书籍中都介绍了可在特定场景提高性能,比如,利用final可能有助于JVM将方法进行内联,可以改善编译器进行条件编译的能力等等。坦白说,很多类似的结论都是基于假设得出的,比如现代高性能JVM(如HotSpot)判断内联未必依赖final的提示,要相信JVM还是非常智能的。类似的,final字段对性能的影响,大部分情况下,并没有考虑的必要。
|
||||||
|
|
||||||
|
从开发实践的角度,我不想过度强调这一点,这是和JVM的实现很相关的,未经验证比较难以把握。我的建议是,在日常开发中,除非有特别考虑,不然最好不要指望这种小技巧带来的所谓性能好处,程序最好是体现它的语义目的。如果你确实对这方面有兴趣,可以查阅相关资料,我就不再赘述了,不过千万别忘了验证一下。
|
||||||
|
|
||||||
|
对于finally,明确知道怎么使用就足够了。需要关闭的连接等资源,更推荐使用Java 7中添加的try-with-resources语句,因为通常Java平台能够更好地处理异常情况,编码量也要少很多,何乐而不为呢。
|
||||||
|
|
||||||
|
另外,我注意到有一些常被考到的finally问题(也比较偏门),至少需要了解一下。比如,下面代码会输出什么?
|
||||||
|
|
||||||
|
try {
|
||||||
|
// do something
|
||||||
|
System.exit(1);
|
||||||
|
} finally{
|
||||||
|
System.out.println(“Print from finally”);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
上面finally里面的代码可不会被执行的哦,这是一个特例。
|
||||||
|
|
||||||
|
对于finalize,我们要明确它是不推荐使用的,业界实践一再证明它不是个好的办法,在Java 9中,甚至明确将Object.finalize()标记为deprecated!如果没有特别的原因,不要实现finalize方法,也不要指望利用它来进行资源回收。
|
||||||
|
|
||||||
|
为什么呢?简单说,你无法保证finalize什么时候执行,执行的是否符合预期。使用不当会影响性能,导致程序死锁、挂起等。
|
||||||
|
|
||||||
|
通常来说,利用上面的提到的try-with-resources或者try-finally机制,是非常好的回收资源的办法。如果确实需要额外处理,可以考虑Java提供的Cleaner机制或者其他替代方法。接下来,我来介绍更多设计考虑和实践细节。
|
||||||
|
|
||||||
|
知识扩展
|
||||||
|
|
||||||
|
|
||||||
|
注意,final不是immutable!
|
||||||
|
|
||||||
|
|
||||||
|
我在前面介绍了final在实践中的益处,需要注意的是,final并不等同于immutable,比如下面这段代码:
|
||||||
|
|
||||||
|
final List<String> strList = new ArrayList<>();
|
||||||
|
strList.add("Hello");
|
||||||
|
strList.add("world");
|
||||||
|
List<String> unmodifiableStrList = List.of("hello", "world");
|
||||||
|
unmodifiableStrList.add("again");
|
||||||
|
|
||||||
|
|
||||||
|
final只能约束strList这个引用不可以被赋值,但是strList对象行为不被final影响,添加元素等操作是完全正常的。如果我们真的希望对象本身是不可变的,那么需要相应的类支持不可变的行为。在上面这个例子中,List.of方法创建的本身就是不可变List,最后那句add是会在运行时抛出异常的。
|
||||||
|
|
||||||
|
Immutable在很多场景是非常棒的选择,某种意义上说,Java语言目前并没有原生的不可变支持,如果要实现immutable的类,我们需要做到:
|
||||||
|
|
||||||
|
|
||||||
|
将class自身声明为final,这样别人就不能扩展来绕过限制了。
|
||||||
|
|
||||||
|
将所有成员变量定义为private和final,并且不要实现setter方法。
|
||||||
|
|
||||||
|
通常构造对象时,成员变量使用深度拷贝来初始化,而不是直接赋值,这是一种防御措施,因为你无法确定输入对象不被其他人修改。
|
||||||
|
|
||||||
|
如果确实需要实现getter方法,或者其他可能会返回内部状态的方法,使用copy-on-write原则,创建私有的copy。
|
||||||
|
|
||||||
|
|
||||||
|
这些原则是不是在并发编程实践中经常被提到?的确如此。
|
||||||
|
|
||||||
|
关于setter/getter方法,很多人喜欢直接用IDE一次全部生成,建议最好是你确定有需要时再实现。
|
||||||
|
|
||||||
|
|
||||||
|
finalize真的那么不堪?
|
||||||
|
|
||||||
|
|
||||||
|
前面简单介绍了finalize是一种已经被业界证明了的非常不好的实践,那么为什么会导致那些问题呢?
|
||||||
|
|
||||||
|
finalize的执行是和垃圾收集关联在一起的,一旦实现了非空的finalize方法,就会导致相应对象回收呈现数量级上的变慢,有人专门做过benchmark,大概是40~50倍的下降。
|
||||||
|
|
||||||
|
因为,finalize被设计成在对象被垃圾收集前调用,这就意味着实现了finalize方法的对象是个“特殊公民”,JVM要对它进行额外处理。finalize本质上成为了快速回收的阻碍者,可能导致你的对象经过多个垃圾收集周期才能被回收。
|
||||||
|
|
||||||
|
有人也许会问,我用System.runFinalization()告诉JVM积极一点,是不是就可以了?也许有点用,但是问题在于,这还是不可预测、不能保证的,所以本质上还是不能指望。实践中,因为finalize拖慢垃圾收集,导致大量对象堆积,也是一种典型的导致OOM的原因。
|
||||||
|
|
||||||
|
从另一个角度,我们要确保回收资源就是因为资源都是有限的,垃圾收集时间的不可预测,可能会极大加剧资源占用。这意味着对于消耗非常高频的资源,千万不要指望finalize去承担资源释放的主要职责,最多让finalize作为最后的“守门员”,况且它已经暴露了如此多的问题。这也是为什么我推荐,资源用完即显式释放,或者利用资源池来尽量重用。
|
||||||
|
|
||||||
|
finalize还会掩盖资源回收时的出错信息,我们看下面一段JDK的源代码,截取自java.lang.ref.Finalizer
|
||||||
|
|
||||||
|
private void runFinalizer(JavaLangAccess jla) {
|
||||||
|
// ... 省略部分代码
|
||||||
|
try {
|
||||||
|
Object finalizee = this.get();
|
||||||
|
if (finalizee != null && !(finalizee instanceof java.lang.Enum)) {
|
||||||
|
jla.invokeFinalize(finalizee);
|
||||||
|
// Clear stack slot containing this variable, to decrease
|
||||||
|
// the chances of false retention with a conservative GC
|
||||||
|
finalizee = null;
|
||||||
|
}
|
||||||
|
} catch (Throwable x) { }
|
||||||
|
super.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
结合我上期专栏介绍的异常处理实践,你认为这段代码会导致什么问题?
|
||||||
|
|
||||||
|
是的,你没有看错,这里的Throwable是被生吞了的!也就意味着一旦出现异常或者出错,你得不到任何有效信息。况且,Java在finalize阶段也没有好的方式处理任何信息,不然更加不可预测。
|
||||||
|
|
||||||
|
|
||||||
|
有什么机制可以替换finalize吗?
|
||||||
|
|
||||||
|
|
||||||
|
Java平台目前在逐步使用java.lang.ref.Cleaner来替换掉原有的finalize实现。Cleaner的实现利用了幻象引用(PhantomReference),这是一种常见的所谓post-mortem清理机制。我会在后面的专栏系统介绍Java的各种引用,利用幻象引用和引用队列,我们可以保证对象被彻底销毁前做一些类似资源回收的工作,比如关闭文件描述符(操作系统有限的资源),它比finalize更加轻量、更加可靠。
|
||||||
|
|
||||||
|
吸取了finalize里的教训,每个Cleaner的操作都是独立的,它有自己的运行线程,所以可以避免意外死锁等问题。
|
||||||
|
|
||||||
|
实践中,我们可以为自己的模块构建一个Cleaner,然后实现相应的清理逻辑。下面是JDK自身提供的样例程序:
|
||||||
|
|
||||||
|
public class CleaningExample implements AutoCloseable {
|
||||||
|
// A cleaner, preferably one shared within a library
|
||||||
|
private static final Cleaner cleaner = <cleaner>;
|
||||||
|
static class State implements Runnable {
|
||||||
|
State(...) {
|
||||||
|
// initialize State needed for cleaning action
|
||||||
|
}
|
||||||
|
public void run() {
|
||||||
|
// cleanup action accessing State, executed at most once
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private final State;
|
||||||
|
private final Cleaner.Cleanable cleanable
|
||||||
|
public CleaningExample() {
|
||||||
|
this.state = new State(...);
|
||||||
|
this.cleanable = cleaner.register(this, state);
|
||||||
|
}
|
||||||
|
public void close() {
|
||||||
|
cleanable.clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
注意,从可预测性的角度来判断,Cleaner或者幻象引用改善的程度仍然是有限的,如果由于种种原因导致幻象引用堆积,同样会出现问题。所以,Cleaner适合作为一种最后的保证手段,而不是完全依赖Cleaner进行资源回收,不然我们就要再做一遍finalize的噩梦了。
|
||||||
|
|
||||||
|
我也注意到很多第三方库自己直接利用幻象引用定制资源收集,比如广泛使用的MySQL JDBC driver之一的mysql-connector-j,就利用了幻象引用机制。幻象引用也可以进行类似链条式依赖关系的动作,比如,进行总量控制的场景,保证只有连接被关闭,相应资源被回收,连接池才能创建新的连接。
|
||||||
|
|
||||||
|
另外,这种代码如果稍有不慎添加了对资源的强引用关系,就会导致循环引用关系,前面提到的MySQL JDBC就在特定模式下有这种问题,导致内存泄漏。上面的示例代码中,将State定义为static,就是为了避免普通的内部类隐含着对外部对象的强引用,因为那样会使外部对象无法进入幻象可达的状态。
|
||||||
|
|
||||||
|
今天,我从语法角度分析了final、finally、finalize,并从安全、性能、垃圾收集等方面逐步深入,探讨了实践中的注意事项,希望对你有所帮助。
|
||||||
|
|
||||||
|
一课一练
|
||||||
|
|
||||||
|
关于今天我们讨论的题目你做到心中有数了吗?也许你已经注意到了,JDK自身使用的Cleaner机制仍然是有缺陷的,你有什么更好的建议吗?
|
||||||
|
|
||||||
|
请你在留言区写写你的建议,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||||
|
|
||||||
|
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
181
专栏/Java核心技术面试精讲/04强引用、软引用、弱引用、幻象引用有什么区别?.md
Normal file
181
专栏/Java核心技术面试精讲/04强引用、软引用、弱引用、幻象引用有什么区别?.md
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
04 强引用、软引用、弱引用、幻象引用有什么区别?
|
||||||
|
在Java语言中,除了原始数据类型的变量,其他所有都是所谓的引用类型,指向各种不同的对象,理解引用对于掌握Java对象生命周期和JVM内部相关机制非常有帮助。
|
||||||
|
|
||||||
|
今天我要问你的问题是,强引用、软引用、弱引用、幻象引用有什么区别?具体使用场景是什么?
|
||||||
|
|
||||||
|
典型回答
|
||||||
|
|
||||||
|
不同的引用类型,主要体现的是对象不同的可达性(reachable)状态和对垃圾收集的影响。
|
||||||
|
|
||||||
|
所谓强引用(“Strong” Reference),就是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还“活着”,垃圾收集器不会碰这种对象。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以被垃圾收集的了,当然具体回收时机还是要看垃圾收集策略。
|
||||||
|
|
||||||
|
软引用(SoftReference),是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当JVM认为内存不足时,才会去试图回收软引用指向的对象。JVM会确保在抛出OutOfMemoryError之前,清理软引用指向的对象。软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
|
||||||
|
|
||||||
|
弱引用(WeakReference)并不能使对象豁免垃圾收集,仅仅是提供一种访问在弱引用状态下对象的途径。这就可以用来构建一种没有特定约束的关系,比如,维护一种非强制性的映射关系,如果试图获取时对象还在,就使用它,否则重现实例化。它同样是很多缓存实现的选择。
|
||||||
|
|
||||||
|
对于幻象引用,有时候也翻译成虚引用,你不能通过它访问对象。幻象引用仅仅是提供了一种确保对象被finalize以后,做某些事情的机制,比如,通常用来做所谓的Post-Mortem清理机制,我在专栏上一讲中介绍的Java平台自身Cleaner机制等,也有人利用幻象引用监控对象的创建和销毁。
|
||||||
|
|
||||||
|
考点分析
|
||||||
|
|
||||||
|
这道面试题,属于既偏门又非常高频的一道题目。说它偏门,是因为在大多数应用开发中,很少直接操作各种不同引用,虽然我们使用的类库、框架可能利用了其机制。它被频繁问到,是因为这是一个综合性的题目,既考察了我们对基础概念的理解,也考察了对底层对象生命周期、垃圾收集机制等的掌握。
|
||||||
|
|
||||||
|
充分理解这些引用,对于我们设计可靠的缓存等框架,或者诊断应用OOM等问题,会很有帮助。比如,诊断MySQL connector-j驱动在特定模式下(useCompression=true)的内存泄漏问题,就需要我们理解怎么排查幻象引用的堆积问题。
|
||||||
|
|
||||||
|
知识扩展
|
||||||
|
|
||||||
|
|
||||||
|
对象可达性状态流转分析
|
||||||
|
|
||||||
|
|
||||||
|
首先,请你看下面流程图,我这里简单总结了对象生命周期和不同可达性状态,以及不同状态可能的改变关系,可能未必100%严谨,来阐述下可达性的变化。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
我来解释一下上图的具体状态,这是Java定义的不同可达性级别(reachability level),具体如下:
|
||||||
|
|
||||||
|
|
||||||
|
强可达(Strongly Reachable),就是当一个对象可以有一个或多个线程可以不通过各种引用访问到的情况。比如,我们新创建一个对象,那么创建它的线程对它就是强可达。
|
||||||
|
|
||||||
|
软可达(Softly Reachable),就是当我们只能通过软引用才能访问到对象的状态。
|
||||||
|
|
||||||
|
弱可达(Weakly Reachable),类似前面提到的,就是无法通过强引用或者软引用访问,只能通过弱引用访问时的状态。这是十分临近finalize状态的时机,当弱引用被清除的时候,就符合finalize的条件了。
|
||||||
|
|
||||||
|
幻象可达(Phantom Reachable),上面流程图已经很直观了,就是没有强、软、弱引用关联,并且finalize过了,只有幻象引用指向这个对象的时候。
|
||||||
|
|
||||||
|
当然,还有一个最后的状态,就是不可达(unreachable),意味着对象可以被清除了。
|
||||||
|
|
||||||
|
|
||||||
|
判断对象可达性,是JVM垃圾收集器决定如何处理对象的一部分考虑。
|
||||||
|
|
||||||
|
所有引用类型,都是抽象类java.lang.ref.Reference的子类,你可能注意到它提供了get()方法:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
除了幻象引用(因为get永远返回null),如果对象还没有被销毁,都可以通过get方法获取原有对象。这意味着,利用软引用和弱引用,我们可以将访问到的对象,重新指向强引用,也就是人为的改变了对象的可达性状态!这也是为什么我在上面图里有些地方画了双向箭头。
|
||||||
|
|
||||||
|
所以,对于软引用、弱引用之类,垃圾收集器可能会存在二次确认的问题,以保证处于弱引用状态的对象,没有改变为强引用。
|
||||||
|
|
||||||
|
但是,你觉得这里有没有可能出现什么问题呢?
|
||||||
|
|
||||||
|
不错,如果我们错误的保持了强引用(比如,赋值给了static变量),那么对象可能就没有机会变回类似弱引用的可达性状态了,就会产生内存泄漏。所以,检查弱引用指向对象是否被垃圾收集,也是诊断是否有特定内存泄漏的一个思路,如果我们的框架使用到弱引用又怀疑有内存泄漏,就可以从这个角度检查。
|
||||||
|
|
||||||
|
|
||||||
|
引用队列(ReferenceQueue)使用
|
||||||
|
|
||||||
|
|
||||||
|
谈到各种引用的编程,就必然要提到引用队列。我们在创建各种引用并关联到相应对象时,可以选择是否需要关联引用队列,JVM会在特定时机将引用enqueue到队列里,我们可以从队列里获取引用(remove方法在这里实际是有获取的意思)进行相关后续逻辑。尤其是幻象引用,get方法只返回null,如果再不指定引用队列,基本就没有意义了。看看下面的示例代码。利用引用队列,我们可以在对象处于相应状态时(对于幻象引用,就是前面说的被finalize了,处于幻象可达状态),执行后期处理逻辑。
|
||||||
|
|
||||||
|
Object counter = new Object();
|
||||||
|
ReferenceQueue refQueue = new ReferenceQueue<>();
|
||||||
|
PhantomReference<Object> p = new PhantomReference<>(counter, refQueue);
|
||||||
|
counter = null;
|
||||||
|
System.gc();
|
||||||
|
try {
|
||||||
|
// Remove是一个阻塞方法,可以指定timeout,或者选择一直阻塞
|
||||||
|
Reference<Object> ref = refQueue.remove(1000L);
|
||||||
|
if (ref != null) {
|
||||||
|
// do something
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
// Handle it
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
显式地影响软引用垃圾收集
|
||||||
|
|
||||||
|
|
||||||
|
前面泛泛提到了引用对垃圾收集的影响,尤其是软引用,到底JVM内部是怎么处理它的,其实并不是非常明确。那么我们能不能使用什么方法来影响软引用的垃圾收集呢?
|
||||||
|
|
||||||
|
答案是有的。软引用通常会在最后一次引用后,还能保持一段时间,默认值是根据堆剩余空间计算的(以M bytes为单位)。从Java 1.3.1开始,提供了-XX:SoftRefLRUPolicyMSPerMB参数,我们可以以毫秒(milliseconds)为单位设置。比如,下面这个示例就是设置为3秒(3000毫秒)。
|
||||||
|
|
||||||
|
-XX:SoftRefLRUPolicyMSPerMB=3000
|
||||||
|
|
||||||
|
|
||||||
|
这个剩余空间,其实会受不同JVM模式影响,对于Client模式,比如通常的Windows 32 bit JDK,剩余空间是计算当前堆里空闲的大小,所以更加倾向于回收;而对于server模式JVM,则是根据-Xmx指定的最大值来计算。
|
||||||
|
|
||||||
|
本质上,这个行为还是个黑盒,取决于JVM实现,即使是上面提到的参数,在新版的JDK上也未必有效,另外Client模式的JDK已经逐步退出历史舞台。所以在我们应用时,可以参考类似设置,但不要过于依赖它。
|
||||||
|
|
||||||
|
|
||||||
|
诊断JVM引用情况
|
||||||
|
|
||||||
|
|
||||||
|
如果你怀疑应用存在引用(或finalize)导致的回收问题,可以有很多工具或者选项可供选择,比如HotSpot JVM自身便提供了明确的选项(PrintReferenceGC)去获取相关信息,我指定了下面选项去使用JDK 8运行一个样例应用:
|
||||||
|
|
||||||
|
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintReferenceGC
|
||||||
|
|
||||||
|
|
||||||
|
这是JDK 8使用ParrallelGC收集的垃圾收集日志,各种引用数量非常清晰。
|
||||||
|
|
||||||
|
0.403: [GC (Allocation Failure) 0.871: [SoftReference, 0 refs, 0.0000393 secs]0.871: [WeakReference, 8 refs, 0.0000138 secs]0.871: [FinalReference, 4 refs, 0.0000094 secs]0.871: [PhantomReference, 0 refs, 0 refs, 0.0000085 secs]0.871: [JNI Weak Reference, 0.0000071 secs][PSYoungGen: 76272K->10720K(141824K)] 128286K->128422K(316928K), 0.4683919 secs] [Times: user=1.17 sys=0.03, real=0.47 secs]
|
||||||
|
|
||||||
|
|
||||||
|
注意:JDK 9对JVM和垃圾收集日志进行了广泛的重构,类似PrintGCTimeStamps和PrintReferenceGC已经不再存在,我在专栏后面的垃圾收集主题里会更加系统的阐述。
|
||||||
|
|
||||||
|
|
||||||
|
Reachability Fence
|
||||||
|
|
||||||
|
|
||||||
|
除了我前面介绍的几种基本引用类型,我们也可以通过底层API来达到强引用的效果,这就是所谓的设置reachability fence。
|
||||||
|
|
||||||
|
为什么需要这种机制呢?考虑一下这样的场景,按照Java语言规范,如果一个对象没有指向强引用,就符合垃圾收集的标准,有些时候,对象本身并没有强引用,但是也许它的部分属性还在被使用,这样就导致诡异的问题,所以我们需要一个方法,在没有强引用情况下,通知JVM对象是在被使用的。说起来有点绕,我们来看看Java 9中提供的案例。
|
||||||
|
|
||||||
|
class Resource {
|
||||||
|
private static ExternalResource[] externalResourceArray = ...
|
||||||
|
int myIndex; Resource(...) {
|
||||||
|
myIndex = ...
|
||||||
|
externalResourceArray[myIndex] = ...;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
protected void finalize() {
|
||||||
|
externalResourceArray[myIndex] = null;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
public void action() {
|
||||||
|
try {
|
||||||
|
// 需要被保护的代码
|
||||||
|
int i = myIndex;
|
||||||
|
Resource.update(externalResourceArray[i]);
|
||||||
|
} finally {
|
||||||
|
// 调用reachbilityFence,明确保障对象strongly reachable
|
||||||
|
Reference.reachabilityFence(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private static void update(ExternalResource ext) {
|
||||||
|
ext.status = ...;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
方法action的执行,依赖于对象的部分属性,所以被特定保护了起来。否则,如果我们在代码中像下面这样调用,那么就可能会出现困扰,因为没有强引用指向我们创建出来的Resource对象,JVM对它进行finalize操作是完全合法的。
|
||||||
|
|
||||||
|
new Resource().action()
|
||||||
|
|
||||||
|
|
||||||
|
类似的书写结构,在异步编程中似乎是很普遍的,因为异步编程中往往不会用传统的“执行->返回->使用”的结构。
|
||||||
|
|
||||||
|
在Java 9之前,实现类似功能相对比较繁琐,有的时候需要采取一些比较隐晦的小技巧。幸好,java.lang.ref.Reference给我们提供了新方法,它是JEP 193: Variable Handles的一部分,将Java平台底层的一些能力暴露出来:
|
||||||
|
|
||||||
|
static void reachabilityFence(Object ref)
|
||||||
|
|
||||||
|
|
||||||
|
在JDK源码中,reachabilityFence大多使用在Executors或者类似新的HTTP/2客户端代码中,大部分都是异步调用的情况。编程中,可以按照上面这个例子,将需要reachability保障的代码段利用try-finally包围起来,在finally里明确声明对象强可达。
|
||||||
|
|
||||||
|
今天,我总结了Java语言提供的几种引用类型、相应可达状态以及对于JVM工作的意义,并分析了引用队列使用的一些实际情况,最后介绍了在新的编程模式下,如何利用API去保障对象不被意外回收,希望对你有所帮助。
|
||||||
|
|
||||||
|
一课一练
|
||||||
|
|
||||||
|
关于今天我们讨论的题目你做到心中有数了吗?给你留一道练习题,你能从自己的产品或者第三方类库中找到使用各种引用的案例吗?它们都试图解决什么问题?
|
||||||
|
|
||||||
|
请你在留言区写写你的答案,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||||
|
|
||||||
|
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享出去,或许你能帮到他。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
180
专栏/Java核心技术面试精讲/05String、StringBuffer、StringBuilder有什么区别?.md
Normal file
180
专栏/Java核心技术面试精讲/05String、StringBuffer、StringBuilder有什么区别?.md
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
05 String、StringBuffer、StringBuilder有什么区别?
|
||||||
|
今天我会聊聊日常使用的字符串,别看它似乎很简单,但其实字符串几乎在所有编程语言里都是个特殊的存在,因为不管是数量还是体积,字符串都是大多数应用中的重要组成。
|
||||||
|
|
||||||
|
今天我要问你的问题是,理解Java的字符串,String、StringBuffer、StringBuilder有什么区别?
|
||||||
|
|
||||||
|
典型回答
|
||||||
|
|
||||||
|
String是Java语言非常基础和重要的类,提供了构造和管理字符串的各种基本逻辑。它是典型的Immutable类,被声明成为final class,所有属性也都是final的。也由于它的不可变性,类似拼接、裁剪字符串等动作,都会产生新的String对象。由于字符串操作的普遍性,所以相关操作的效率往往对应用性能有明显影响。
|
||||||
|
|
||||||
|
StringBuffer是为解决上面提到拼接产生太多中间对象的问题而提供的一个类,我们可以用append或者add方法,把字符串添加到已有序列的末尾或者指定位置。StringBuffer本质是一个线程安全的可修改字符序列,它保证了线程安全,也随之带来了额外的性能开销,所以除非有线程安全的需要,不然还是推荐使用它的后继者,也就是StringBuilder。
|
||||||
|
|
||||||
|
StringBuilder是Java 1.5中新增的,在能力上和StringBuffer没有本质区别,但是它去掉了线程安全的部分,有效减小了开销,是绝大部分情况下进行字符串拼接的首选。
|
||||||
|
|
||||||
|
考点分析
|
||||||
|
|
||||||
|
几乎所有的应用开发都离不开操作字符串,理解字符串的设计和实现以及相关工具如拼接类的使用,对写出高质量代码是非常有帮助的。关于这个问题,我前面的回答是一个通常的概要性回答,至少你要知道String是Immutable的,字符串操作不当可能会产生大量临时字符串,以及线程安全方面的区别。
|
||||||
|
|
||||||
|
如果继续深入,面试官可以从各种不同的角度考察,比如可以:
|
||||||
|
|
||||||
|
|
||||||
|
通过String和相关类,考察基本的线程安全设计与实现,各种基础编程实践。
|
||||||
|
|
||||||
|
考察JVM对象缓存机制的理解以及如何良好地使用。
|
||||||
|
|
||||||
|
考察JVM优化Java代码的一些技巧。
|
||||||
|
|
||||||
|
String相关类的演进,比如Java 9中实现的巨大变化。
|
||||||
|
|
||||||
|
…
|
||||||
|
|
||||||
|
|
||||||
|
针对上面这几方面,我会在知识扩展部分与你详细聊聊。
|
||||||
|
|
||||||
|
知识扩展
|
||||||
|
|
||||||
|
|
||||||
|
字符串设计和实现考量
|
||||||
|
|
||||||
|
|
||||||
|
我在前面介绍过,String是Immutable类的典型实现,原生的保证了基础线程安全,因为你无法对它内部数据进行任何修改,这种便利甚至体现在拷贝构造函数中,由于不可变,Immutable对象在拷贝时不需要额外复制数据。
|
||||||
|
|
||||||
|
我们再来看看StringBuffer实现的一些细节,它的线程安全是通过把各种修改数据的方法都加上synchronized关键字实现的,非常直白。其实,这种简单粗暴的实现方式,非常适合我们常见的线程安全类实现,不必纠结于synchronized性能之类的,有人说“过早优化是万恶之源”,考虑可靠性、正确性和代码可读性才是大多数应用开发最重要的因素。
|
||||||
|
|
||||||
|
为了实现修改字符序列的目的,StringBuffer和StringBuilder底层都是利用可修改的(char,JDK 9以后是byte)数组,二者都继承了AbstractStringBuilder,里面包含了基本操作,区别仅在于最终的方法是否加了synchronized。
|
||||||
|
|
||||||
|
另外,这个内部数组应该创建成多大的呢?如果太小,拼接的时候可能要重新创建足够大的数组;如果太大,又会浪费空间。目前的实现是,构建时初始字符串长度加16(这意味着,如果没有构建对象时输入最初的字符串,那么初始值就是16)。我们如果确定拼接会发生非常多次,而且大概是可预计的,那么就可以指定合适的大小,避免很多次扩容的开销。扩容会产生多重开销,因为要抛弃原有数组,创建新的(可以简单认为是倍数)数组,还要进行arraycopy。
|
||||||
|
|
||||||
|
前面我讲的这些内容,在具体的代码书写中,应该如何选择呢?
|
||||||
|
|
||||||
|
在没有线程安全问题的情况下,全部拼接操作是应该都用StringBuilder实现吗?毕竟这样书写的代码,还是要多敲很多字的,可读性也不理想,下面的对比非常明显。
|
||||||
|
|
||||||
|
String strByBuilder = new
|
||||||
|
StringBuilder().append("aa").append("bb").append("cc").append
|
||||||
|
("dd").toString();
|
||||||
|
|
||||||
|
String strByConcat = "aa" + "bb" + "cc" + "dd";
|
||||||
|
|
||||||
|
|
||||||
|
其实,在通常情况下,没有必要过于担心,要相信Java还是非常智能的。
|
||||||
|
|
||||||
|
我们来做个实验,把下面一段代码,利用不同版本的JDK编译,然后再反编译,例如:
|
||||||
|
|
||||||
|
public class StringConcat {
|
||||||
|
public static String concat(String str) {
|
||||||
|
return str + “aa” + “bb”;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
先编译再反编译,比如使用不同版本的JDK:
|
||||||
|
|
||||||
|
${JAVA_HOME}/bin/javac StringConcat.java
|
||||||
|
${JAVA_HOME}/bin/javap -v StringConcat.class
|
||||||
|
|
||||||
|
|
||||||
|
JDK 8的输出片段是:
|
||||||
|
|
||||||
|
0: new #2 // class java/lang/StringBuilder
|
||||||
|
3: dup
|
||||||
|
4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
|
||||||
|
7: aload_0
|
||||||
|
8: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
|
||||||
|
11: ldc #5 // String aa
|
||||||
|
13: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
|
||||||
|
16: ldc #6 // String bb
|
||||||
|
18: invokevirtual #4 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
|
||||||
|
21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
|
||||||
|
|
||||||
|
|
||||||
|
而在JDK 9中,反编译的结果就会有点特别了,片段是:
|
||||||
|
|
||||||
|
// concat method
|
||||||
|
1: invokedynamic #2, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
|
||||||
|
|
||||||
|
// ...
|
||||||
|
// 实际是利用了MethodHandle,统一了入口
|
||||||
|
0: #15 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
|
||||||
|
|
||||||
|
|
||||||
|
你可以看到,非静态的拼接逻辑在JDK 8中会自动被javac转换为StringBuilder操作;而在JDK 9里面,则是体现了思路的变化。Java 9利用InvokeDynamic,将字符串拼接的优化与javac生成的字节码解耦,假设未来JVM增强相关运行时实现,将不需要依赖javac的任何修改。
|
||||||
|
|
||||||
|
在日常编程中,保证程序的可读性、可维护性,往往比所谓的最优性能更重要,你可以根据实际需求酌情选择具体的编码方式。
|
||||||
|
|
||||||
|
|
||||||
|
字符串缓存
|
||||||
|
|
||||||
|
|
||||||
|
我们粗略统计过,把常见应用进行堆转储(Dump Heap),然后分析对象组成,会发现平均25%的对象是字符串,并且其中约半数是重复的。如果能避免创建重复字符串,可以有效降低内存消耗和对象创建开销。
|
||||||
|
|
||||||
|
String在Java 6以后提供了intern()方法,目的是提示JVM把相应字符串缓存起来,以备重复使用。在我们创建字符串对象并调用intern()方法的时候,如果已经有缓存的字符串,就会返回缓存里的实例,否则将其缓存起来。一般来说,JVM会将所有的类似“abc”这样的文本字符串,或者字符串常量之类缓存起来。
|
||||||
|
|
||||||
|
看起来很不错是吧?但实际情况估计会让你大跌眼镜。一般使用Java 6这种历史版本,并不推荐大量使用intern,为什么呢?魔鬼存在于细节中,被缓存的字符串是存在所谓PermGen里的,也就是臭名昭著的“永久代”,这个空间是很有限的,也基本不会被FullGC之外的垃圾收集照顾到。所以,如果使用不当,OOM就会光顾。
|
||||||
|
|
||||||
|
在后续版本中,这个缓存被放置在堆中,这样就极大避免了永久代占满的问题,甚至永久代在JDK 8中被MetaSpace(元数据区)替代了。而且,默认缓存大小也在不断地扩大中,从最初的1009,到7u40以后被修改为60013。你可以使用下面的参数直接打印具体数字,可以拿自己的JDK立刻试验一下。
|
||||||
|
|
||||||
|
-XX:+PrintStringTableStatistics
|
||||||
|
|
||||||
|
|
||||||
|
你也可以使用下面的JVM参数手动调整大小,但是绝大部分情况下并不需要调整,除非你确定它的大小已经影响了操作效率。
|
||||||
|
|
||||||
|
-XX:StringTableSize=N
|
||||||
|
|
||||||
|
|
||||||
|
Intern是一种显式地排重机制,但是它也有一定的副作用,因为需要开发者写代码时明确调用,一是不方便,每一个都显式调用是非常麻烦的;另外就是我们很难保证效率,应用开发阶段很难清楚地预计字符串的重复情况,有人认为这是一种污染代码的实践。
|
||||||
|
|
||||||
|
幸好在Oracle JDK 8u20之后,推出了一个新的特性,也就是G1 GC下的字符串排重。它是通过将相同数据的字符串指向同一份数据来做到的,是JVM底层的改变,并不需要Java类库做什么修改。
|
||||||
|
|
||||||
|
注意这个功能目前是默认关闭的,你需要使用下面参数开启,并且记得指定使用G1 GC:
|
||||||
|
|
||||||
|
-XX:+UseStringDeduplication
|
||||||
|
|
||||||
|
|
||||||
|
前面说到的几个方面,只是Java底层对字符串各种优化的一角,在运行时,字符串的一些基础操作会直接利用JVM内部的Intrinsic机制,往往运行的就是特殊优化的本地代码,而根本就不是Java代码生成的字节码。Intrinsic可以简单理解为,是一种利用native方式hard-coded的逻辑,算是一种特别的内联,很多优化还是需要直接使用特定的CPU指令,具体可以看相关源码,搜索“string”以查找相关Intrinsic定义。当然,你也可以在启动实验应用时,使用下面参数,了解intrinsic发生的状态。
|
||||||
|
|
||||||
|
-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
|
||||||
|
//样例输出片段
|
||||||
|
180 3 3 java.lang.String::charAt (25 bytes)
|
||||||
|
@ 1 java.lang.String::isLatin1 (19 bytes)
|
||||||
|
...
|
||||||
|
@ 7 java.lang.StringUTF16::getChar (60 bytes) intrinsic
|
||||||
|
|
||||||
|
|
||||||
|
可以看出,仅仅是字符串一个实现,就需要Java平台工程师和科学家付出如此大且默默无闻的努力,我们得到的很多便利都是来源于此。
|
||||||
|
|
||||||
|
我会在专栏后面的JVM和性能等主题,详细介绍JVM内部优化的一些方法,如果你有兴趣可以再深入学习。即使你不做JVM开发或者暂时还没有使用到特别的性能优化,这些知识也能帮助你增加技术深度。
|
||||||
|
|
||||||
|
|
||||||
|
String自身的演化
|
||||||
|
|
||||||
|
|
||||||
|
如果你仔细观察过Java的字符串,在历史版本中,它是使用char数组来存数据的,这样非常直接。但是Java中的char是两个bytes大小,拉丁语系语言的字符,根本就不需要太宽的char,这样无区别的实现就造成了一定的浪费。密度是编程语言平台永恒的话题,因为归根结底绝大部分任务是要来操作数据的。
|
||||||
|
|
||||||
|
其实在Java 6的时候,Oracle JDK就提供了压缩字符串的特性,但是这个特性的实现并不是开源的,而且在实践中也暴露出了一些问题,所以在最新的JDK版本中已经将它移除了。
|
||||||
|
|
||||||
|
在Java 9中,我们引入了Compact Strings的设计,对字符串进行了大刀阔斧的改进。将数据存储方式从char数组,改变为一个byte数组加上一个标识编码的所谓coder,并且将相关字符串操作类都进行了修改。另外,所有相关的Intrinsic之类也都进行了重写,以保证没有任何性能损失。
|
||||||
|
|
||||||
|
虽然底层实现发生了这么大的改变,但是Java字符串的行为并没有任何大的变化,所以这个特性对于绝大部分应用来说是透明的,绝大部分情况不需要修改已有代码。
|
||||||
|
|
||||||
|
当然,在极端情况下,字符串也出现了一些能力退化,比如最大字符串的大小。你可以思考下,原来char数组的实现,字符串的最大长度就是数组本身的长度限制,但是替换成byte数组,同样数组长度下,存储能力是退化了一倍的!还好这是存在于理论中的极限,还没有发现现实应用受此影响。
|
||||||
|
|
||||||
|
在通用的性能测试和产品实验中,我们能非常明显地看到紧凑字符串带来的优势,即更小的内存占用、更快的操作速度。
|
||||||
|
|
||||||
|
今天我从String、StringBuffer和StringBuilder的主要设计和实现特点开始,分析了字符串缓存的intern机制、非代码侵入性的虚拟机层面排重、Java 9中紧凑字符的改进,并且初步接触了JVM的底层优化机制intrinsic。从实践的角度,不管是Compact Strings还是底层intrinsic优化,都说明了使用Java基础类库的优势,它们往往能够得到最大程度、最高质量的优化,而且只要升级JDK版本,就能零成本地享受这些益处。
|
||||||
|
|
||||||
|
一课一练
|
||||||
|
|
||||||
|
关于今天我们讨论的题目你做到心中有数了吗?限于篇幅有限,还有很多字符相关的问题没有来得及讨论,比如编码相关的问题。可以思考一下,很多字符串操作,比如getBytes()/String(byte[] bytes)等都是隐含着使用平台默认编码,这是一种好的实践吗?是否有利于避免乱码?
|
||||||
|
|
||||||
|
请你在留言区写写你对这个问题的思考,或者分享一下你在操作字符串时掉过的坑,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||||
|
|
||||||
|
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user