first commit

This commit is contained in:
张乾 2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View File

@ -0,0 +1,176 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 JavaScript的进阶之路
你好我是石川欢迎和我一起学习JavaScript。
JavaScript的前身Mocha是布兰登·艾克Brendan Eich在1995年用10天时间设计出来的。我第一次接触JavaScript是从它诞生算起的10年后在学校学习的那时的网站更多是静态的信息展示类网页。所以当时JavaScript和它刚发明出来时类似更多是一门脚本语言。可以说整个前端在那个年代和之前的很长时间都被认为是一个很业余的技能。
但随着AJAX在2005年诞生接着W3C在2006年第一次发布了XHRXMLHttpRequest规范草案后这种情况就改变了。基于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

View 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中的常量constconstant算不算不可变呢
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
延伸阅读
Beginning Functional JavaScript
Function Light JS
JavaScript世界的一等公民——函数
Mastering JavaScript Functional Programming
You Dont Know JS: this & Object Prototypes
JavaScript Patterns
Learning JavaScript Design Patterns

View 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

View 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

View 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)返回的结果是trueisOdd(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 ,那么你知道这背后的原理吗?欢迎在留言区分享你的答案,或者你如果对此并不十分了解,也希望你能找找资料,作为下节课的预习内容。
当然,你也可以在评论区交流下自己的疑问,我们一起讨论、共同进步。

View 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) // => truea里面有小于6的数字
arr.some(x => x % 2 === 1) // => true数组a里面有一些奇数
虽然some() 和 every() 都是 JavaScript自带的断言方法但是对比 filter() ,它们就显得没有那么“函数式”了,因为它们的返回值只是一个 true 或 false而没有像 filter 一样返回一组数据作为输出,继续用来进行后续一系列的函数式的操作。
reduce和缩减器
最后我们再来说说reduce。实际上缩减reduce主要的作用就是把列表中的值合成一个值。如下图所示
在reduce当中有一个缩减器reducer函数和一个初始值。比如在下面的例子中初始值是3reducer函数会计算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吗
欢迎在留言区分享你的思考和答案,也欢迎你把今天的内容分享给更多的朋友。

View 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这种方式经常用在长页面当中。比如产品详情页一般都是用讲故事的方式一步步介绍产品卖点用图说话最后再展示参数、一键购买以及加购物车的部分。所以也就是说我们不需要一上来就加载整个页面而是当用户滑动到了某个部分的时候再加载相关的内容。
交互时加载就是当用户和页面进行交互时,比如点击了某个按钮后,可能产生的加载。举个例子,有些应用中的日历,只有用户在进行特定操作的时候才会显示并且和用户交互。这样的模块,我们就可以考虑动态加载。
注意这里有几个重要的指标。在初始化的加载中我们关注的通常是首次渲染时间FCPFirst Contentful Paint和最大内容渲染时间LCPLargest Contentful Paint也就是页面首次加载的时候。在后续的动态加载中我们关注的是首次交互时间TTITime to Interactive也就是当用户开始从首屏开始往下滑动或者点击了某个按钮开启了日历弹窗的时候。
你可能会觉得这样的优化只能省下一两百KB或几个MB是否值得但是苍蝇腿也是肉而且积少成多当你在开发一个复杂的Web应用需要不断扩充模块的时候这样的操作可能就会产生质和量上的变化。
然后在这个时候我们通常会通过一些打包工具比如用Webpack先加载核心的组件渲染主程序之后根据交互的需要按需加载某个模块。
另外对于动态的加载其实也有很多三方的库可以支持其中一个例子就是React中的Suspense。如果是Node服务器端的加载渲染的话也有Loadable Components这样的库可以用来参考。当然如果你不使用这些三方的库自己开发也可以但是原理始终是类似的。
不过这里我还想说下在使用动态导入前一般应该先考虑预加载pre-load或预获取pre-fetch
它们两个的区别是,前者是在页面开始加载时就提前开始加载后面需要用到的元素;后者是在页面的主要功能都加载完成后,再提前加载后面需要用到的素材。除非没法做到预加载和预获取,或者你加载的是三方的内容,不可控,不然的话,这些方式都可以带来比动态加载更好的用户体验。
那么,有没有没法儿做到、或者不适合做预加载的例子呢?也是有的,比如要预加载的内容过大,而且用户不一定会使用预加载的内容的时候。
这个时候如果你事先加载一是会使用到用户的网络占用了用户手机的存储空间二是也会增加自己的CDN和服务器的资源消耗。这种情况下就要用到动态加载了。
另外在进一步看动态加载前,我们还要了解两个基础概念,就是页面渲染的两种基础渲染模式。一种是浏览器渲染,一种是服务器端渲染。
首先在客户端渲染CSRclient side rendering模式下我们是先下载HTML、JS和CSS包括数据所有的内容都下载完成然后再开始渲染。
而SSR服务器端渲染SSRserver side rendering模式下我们是先让用户能看到一个完整的页面但是无法交互。只有等相关数据从服务器端加载和hydrate后比如说一个按钮加上了的相关事件处理之后才能交互。
这个方案看上去比CSR会好一些但它也不是没有问题的。比如说我们作为用户使用一些应用有时候也会遇到类似的问题就是我们在加载和hydrate前点击某个按钮的时候就会发现某个组件没反应。
那么在交互驱动的动态加载中上面这种问题怎么解决呢比如Google他们会使用并且开源了一个叫JSAction 的小工具它的作用就是先加载一部分轻代码tiny code这部分代码可以“记住”用户的行为然后根据用户的交互来加载组件等加载完成再让组件执行之前“记住”的用户请求。这样就完美解决了上述问题。
总结
通过今天的学习,我们理解了函数式编程+响应式编程中时间是一个状态而且是一个最难管理的状态。而通过promise的概念我们可以消除时间并且可以通过同步的方式来处理异步事件。
另外,通过观察者模式我们也可以更好地应对未知,通过行动来感知和响应,这样的加载方式,在应用的使用者达到一定规模化的时候,可以减少不必要和大量的资源浪费。
思考题
我们说根据事件的动态加载可以起到降本增效的作用,那么你能说说你在前端开发中做资源加载设计、分析和优化的经验吗?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

View 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这些更底层的方式实现同样的功能。在后面的两节课里我们会继续从单个对象延伸到对象间的“生产关系”来进一步理解面向对象的编程模式。
思考题
我们今天尝试通过去掉语法糖,用更底层的方式实现了对象中的私有属性,那么你能不能自己动手试试去掉静态属性的语法糖,来实现类似的功能?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

View File

@ -0,0 +1,343 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 深入理解继承、Delegation和组合
你好,我是石川。
关于面向对象编程最著名的一本书就数GoFGang 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()吗?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课见!

View 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 Stringnew 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 效果一样吗?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

View 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,00014后面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); // 返回 类型错误
// 方式2constructor
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”, age25}当我们将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。可以说除了有特殊需求会用到表达式以外声明式就是默认的写法下面是一个声明式函数的抽象语法树ASTabstract 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来创建那你觉得哪种方式在不同场景下更适合呢
欢迎在留言区分享你的答案和见解,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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()来对它进行调用,解决这个问题的办法就是使用一个立刻调用的函数表达 IIFEimmediately 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开始引进了块级作用域。希望你通过对原理的了解能够更加清楚它们的使用方法。
思考题
在讲到函数式编程时,我们说到了闭包可以作为和对象相比的数据存储结构,在讲到面向对象编程模式时,我们也说到了它可以用来创建对象的私有属性。那么除了这些例子外,你还能举例说明它的其它作用吗?
欢迎在留言区分享你的积累,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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。
11235813213455
如果用迭代的方式来写计算第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%算法都离不开它的影子。请记住这个二八定律,只要把迭代和递归的概念吃透搞明白,对算法的学习,可以说是有着事半功倍的效果。
如果要对比迭代和递归的话,从整体的性能来说,迭代是优于递归的。而如果从代码的整洁性来看,递归看起来更简洁。而且递归和作用域、闭包结合起来形成的记忆函数和尾递归,都能从一定程度上减少其“副作用”。下面我们就结合这张图,总结下针对这些副作用的解决方法吧。
所以在算法中我们应该用迭代还是递归呢?这里同样没有绝对,应用上,你可以根据它们的优劣势,结合实际情况来应用。我个人认为,我们写的代码主要还是给人读的,而不是最终的机器码。所以我的建议是以代码的“简洁可读性”为先,然后再针对机器无法替我们优化的“副作用”的部分所产生的问题做手动的优化。
思考题
前面我们讲到了针对栈溢出的尾调用优化,你知道尾递归调用优化是如何实现的吗?
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。

View 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引擎用的是什么算法
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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-codegenCrankshaftIgnition和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分析器、栈的异常跟踪、分层的集成及热循环到优化代码的栈替换OSRon-stack replacement等工作。
那Sparkplug则用了一个比较聪明的方法简化了大多数问题就是它维护了“与解释器兼容的栈帧”。我们知道调用栈是代码执行存储函数状态的方式而每当我们调用一个新函数时它都会为该函数的本地变量创建一个新的栈帧。 栈帧由帧指针(标记其开始)和栈指针(标记其结束)定义。
当一个函数被调用时,返回地址也会被压入栈内的。返回地址在返回时由函数弹出,以便知道返回到哪里。当该函数创建一个新栈帧时,也会将旧的帧指针保存在栈中,并将新的帧指针设置为它自己栈帧的开头。因此,栈有一系列的帧指针 ,每个都标记指向前一个栈帧的开始。
除了函数的本地变量和回调地址外,栈中还会有传参和储值。参数(包括接收者)在调用函数之前以相反的顺序压入栈内,帧指针前面的几个栈槽是当前正在被调用的函数、上下文,以及传递的参数数量。这是“标准” JS 框架布局:
为了使我们在性能分析时,以最小的成本遍历栈,这种 JS 调用约定在优化和解释栈帧之间是共享的。
Ignition解释器会进一步让调用约定变得更加明确。Ignition是基于寄存器的解释器和机器寄存器的不同在于它是一个虚拟寄存器。它的作用是存储解释器的当前状态包括JavaScript函数局部变量var/let/const 声明)和临时值。这些寄存器存储在解释器的栈帧中。除此以外,栈帧中还有一个指向正在执行的字节码数组的指针,以及当前字节码在该数组中的偏移量。
后来V8团队对解释器栈帧做了一个小改动就是Sparkplug在代码执行期间不再保留最新的字节码偏移量改为了存储从Sparkplug代码地址范围到相应字节码偏移量的双向映射。因为Sparkplug代码是直接从字节码的线性遍历中发出的所以这是一个相对简单的编码映射。每当栈帧访问并想知道Sparkplug栈帧的“字节码偏移量”时都会在映射中查找当前正在执行的指令并返回相应的字节码偏移量。同样每当它想从解释器到Sparkplug进行栈替换OSRon-stack replacement都可以在映射中查找当前字节码偏移量并跳转到相应的Sparkplug指令。
Sparkplug特意创建并维护与解释器相匹配的栈帧布局每当解释器存储一个寄存器值时Sparkplug也会存储一个。它这样做有几点好处一是简化了Sparkplug的编译, Sparkplug可以只镜像解释器的行为而不必保留从解释器寄存器到Sparkplug状态的映射。二是它还加快了编译速度因为字节码编译器已经完成了寄存器分配的繁琐的工作。三是它与系统其余部分如调试器、分析器的集成是基本适配的。四是任何适用于解释器的栈替换OSRon-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
隔壁班宫文学老师的课程:《编译原理之美》

View 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)
HashMapJava是如何解决散列冲突的
先把JS放在一边其实我们也可以通过Java语言里一些更高阶的链式数据结构来更深入了解下哈希碰撞的解决方式。如果你学过Java可能有用到过HashMap、LinkedHashMap和TreeMap。那么Java中的HashMap和LinkedHashMap那么Java中的这些数据结构有什么优势分别是如何实现的下面我们可以来看看。
HashMap的底层逻辑是通过链表和红黑树实现的。它最主要解决的问题就是哈希碰撞。我们先来说说链表。它的规则是当哈希函数生成的哈希值有冲突的时候就把有冲突的数据放到一个链表中以此来解决哈希碰撞。那你可能会问既然链表已经解决了这个问题为什么还需要用到红黑树这是因为当链表中元素的长度比较小的时候链表性能还是可以的但是当冲突的数据过多的时候它就会产生性能上的问题这个时候用增删改查的红黑树来代替会更合适。
散列加链表:基于双链表存值排序
了解完HashMap再来看看LinkedHashMap。LinkedHashMap是在HashMap的基础上内部维持了一个双向链表Doubly Linked List它利用了双向链表的性能特点可以起到另外一个非常重要的作用就是可以保持插入的数据元素的顺序。
TreeMap基于红黑树的键值排序
除了HashMap和LinkedHashMapTreeMap也是Java一种基于红黑树实现的字典但是它和HashMap有着本质的不同它完全不是基于散列表的。而是基于红黑树来实现的。相比HashMap的无序和LinkedHashMap的存值有序TreeMap实现的是键值有序。它的查询效率不如HashMap和LinkedHashMap但是相比前两者它是线程安全的。
总结
通过这节课你应该了解了如何通过哈希查找JS对象的内存地址。不过更重要的是希望通过今天的学习你也能更好地理解哈希、散列表、字典这些初学者都比较容易混淆的概念。最后我们再来总结下吧。我们说字典dictionary也被称为映射、符号表或关联数组哈希表hash table是它的一种实现方式。在ES6之后随着字典Map这种数据结构的引入可以用来实现字典。集合Set和映射类似但是区别是集合只保存值不保存键。举个例子这就好比一个只有单词没有解释的字典。
思考题
今天的思考题是我们知道Map是在ES6之后才引入的在此之前人们如果想实现类似字典的数据结构和功能会通过对象数据类型那你能不能用对象手动实现一个字典的数据结构和相关的方法呢来动手试试吧。
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节再见!

View File

@ -0,0 +1,105 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 为什么环形队列适合做Node数据流缓存
你好,我是石川。
前面几讲讲完了栈这种数据结构我们再来看看队列queue。队列对于你来说可能不算是一种陌生的数据结构和栈相反列队通常遵循的是先进先出FIFOFirst 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的数据请求和反馈还是文件到目的地的传输这些数据的传输都会用到环形队列缓冲。
思考题
在我们用互斥锁的时候,会发现它有一个劣势,就是共享资源,也就是环形队列每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程,这在某种意义上是一个悲观锁。除此之外,还有另外的一种方式是原子操作,它是针对某个值的单个互斥操作。你知道如何通过原子操作来实现环形队列缓冲吗?
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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。循环链表顾名思义就是一个头尾相连的链表。
如果是双向循环列表的话,就是除了顺时针的头尾相接以外,从逆时针也可以循环的双循环链表。
如何通过链表来做缓存
了解了单双循环链表后现在我们回到题目那我们如何能通过链表来做缓存呢我们先了解下缓存的目的。缓存主要做的就是把数据放到临时的内存空间便于再次访问。比如数据库会有缓存目的是减少对硬盘的访问我们的浏览器也有缓存目的是再次访问页面的时候不用重新下载相关的文本、图片或其它素材。通过链表我们可以做到缓存。下面我们会看两种缓存的算法他们是最近最少使用LRUleast recently used和最不经常使用LFUleast 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的应用场景比较少但是它在某些特定场合还是很有用处的你能想到一些相关的场景和实现吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

View File

@ -0,0 +1,189 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 TurboFan如何用图做JS编译优化
你好,我是石川。
今天我们来看下图graph这种数据结构图是一个抽象化的网络结构模型。你可能会问有什么算法应用会用到图这种结构吗其实很多像我们经常用的社交媒体比如国内的微博、微信或者国外的脸书、领英中的社交图谱都可以通过图来表达。另外图也能用来表达现实世界中的路网、空网以及虚拟的通信网络。
图在JS引擎的编译器中作用是非常大的。如果说整个V8的编译器TurboFan都基于图也毫不夸张。我们既可以通过图这种数据结构对编译的原理有更深的理解在我们了解编译的同时又可以对相关的数据结构和算法有更深入的认识。
图的结构
下面我们先来看一下图的结构吧。图就是由边edge连接的节点vertax任何一个二分的关系都可以通过图来表示。
那我们说的TurboFan是怎么利用图来做编译的呢在开始前我们先来了解下编译流程和中间代码。
编译流程:中间代码
IR也就是中间代码Intermediate Representation有时也称 Intermediate CodeIC。从概念层面看IR可以分为HIRHigher IR、MIRMiddle IR和LIRLower IR这几种高、中、低的中间代码的形式。
从上面的图中,我们可以看到在整个编译过程中,可以把它分成前中后三个端。前端主要做的是词法、语法和语义的分析,这个我们在之前的章节也有讲过。中端会做相关的优化工作,最后后端的部分就生成了目标代码。
前面我们说了中间代码可以分为高、中、低三个不同的抽象层次。HIR主要用于基于源语言做的一些分析和变换比较典型的例子就是用于语义分析的的ASTAbstract Syntax Parser语法树。MIR则是独立于源语言和 CPU 架构来做一些分析和优化,比较典型的例子就是三地址代码 TACThree Address Code和程序依赖图 PDGProgram Dependency Graph它们主要用于分析和优化算法的部分。LIR这一层则是依赖于 CPU 架构做优化和代码生成,其中比较典型的例子就是有向无环图 DAGDirected Ayclic Graph它的主要目的是帮助生成目标代码。
在编译流程中分析的这一段里有一个重要的分析就是控制流分析CFAControl Flow Analysis和数据流分析DFA, Data Flow Analysis而我们后面要说的节点之海SoNSea of Node就是可以同时反映数据流和控制流的一种数据结构节点之海可以减少CFA和DFA之间的互相依赖因而更有利于全局优化。
在编译流程中优化的这一段里,也有几个重要的概念,分别是通用优化、对象优化和函数式优化。比如循环优化就属于通用的优化;内联和逃逸属于关于对象的优化。对于函数式编程而言,比较重要的就是之前,我们在说到迭代和递归的时候,提到过的循环和尾递归的优化。
在流程中最后生成目标代码的这一段里,重点的任务是寄存器分配和指令的选择和排序。
今天,我们重点来看一下在分析过程中,图和节点之海的原理,以及为什么说节点之海有利于全局的优化。
中端优化:分析和优化
下面我们从中端的优化说起通过IR的结构你可能可以看出“中间代表”可能是比“中间代码”更准确的一种描述。因为IR更多的是以一种“数据结构”的形式表现也就是我们说的线性列表、树、图这些数据结构而不是“代码”。今天我们重点说的这种数据结构呢就是TurboFan用到的节点之海 SoNSea 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是符合静态单赋值SSAstatic single assignment格式的。什么是静态单赋值呢顾名思义就是每个变量只有唯一的赋值。比如下面的例子里第一个操作是x等于3乘以7之后我们给x加3。这时x变量就出现了两次。所以TurboFan在创建图的时候会给本地变量做重命名在改完之后就是3这个值的节点同时有两个操作符指向它一个是乘以7的结果一个是结果再加上3。在SoN中没有本地变量的概念所以变量被节点取代了。
数据流表达
我们前面说过在分析的过程中有一个重要的分析就是控制流分析CFAControl Flow Analysis和数据流分析DFA, Data Flow Analysis。在数据流中节点用来表达一切运算包括常量、参数、数学运算、加载、存储、调用等等。
关于数据流的边,有两个核心概念,一是实线边表达了数据流的方向,这个决定了输入和输出的关系,下面实线表达的就是运算输出的结果。二是虚线边影响了数据流中的相关数据的读写状态,下面虚线边表示运算读写状态的指定。
关于WAWWrite-After-Write写后再写、WARWrite-After-Read先读后写和RAWRead-After-Write先写后读的读写状态我们可以看看是如何实现的。我们可以看到SoN中的虚线边可以用来表达不同读写状态的影响。比下面obj对象的val属性状态是可变的完成加val加3的操作后通过先读再写存入之后的结果值先写后读。
控制流表达
我们前面说过,从数据流的角度来看,节点和边表达了一切。而在控制流中,这种说法也是同样成立的。通过下面的例子,我们可以看到,控制中的节点包括开始、分支、循环、合并以及结束等等,都是通过节点来表示。
在Turbolizer的可视化图中用黄色节点代表了控制流的节点。下面是几个单纯看数据流的例子我们可以看到从最简单的只包含开始结束的直线程序到分支到循环都可以通过节点之海来表达。
下面我们来看看在和数据流结合后的一个最简单的例子。这里我们看到了,为了区分不同类型的边,可以通过黑色实线、灰色实线和虚线边分别代表控制流、数据流和影响关系。
最后我们再加上合并可以看到一个更完整的例子。这里你会看到一个phi指令这个指令是做什么的呢它会根据分支判断后的实际情况来确定x的值。因为我们说过在SSA静态单赋值的规则中变量只能赋值一次那么我们在遇到分支后的合并就必须用到phi。
分析:变量类型和范围
我们讲了中间代码的重要作用是分析和优化为什么要做变量类型和范围的分析呢这里主要要解决三个问题。第一我们说JavaScript是动态而不是静态类型的所以虽然值有固定的数据类型可变量是没有固定的数据类型的。举个例子我们可以说 var a = 2525本身是不变的数字类型但是如果我们说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节点之海最后排序的结果会被放入一个CFGControl 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

View File

@ -0,0 +1,188 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 通过树和图看如何在无序中找到路径和秩序
你好,我是石川。
在算法中,最常见的两个操作莫过于排序和搜索了。前面我们通过数组了解了在线性的数据结构中不同的排序算法。之后,我们又通过哈希算法了解了散列表这种比较特殊的线性表,了解了它是如何与链表结合,用空间换时间地支持索引和查找的。
在现实的生活中,我们可以看到信息并不总是有序的,我们在讲到函数式编程的时候有提到过,函数思想的一个维度就是如何处理未知和混沌。那么函数中的数据结构和算法呢,作为现实世界的映射,也要能处理这样复杂的情况。今天,我们来看两种支持我们处理这种未知和混沌的非线性的数据结构。一种是树,另外一种是图。
我们知道一些大的咨询公司如麦肯锡,经常用到“金字塔原理”来做解决方案。它的核心原理呢,讲的就是我们平时接收的信息是杂乱无章的网状结构的,而我们要做的是在杂乱无章的信息中梳理出“金字塔”式的树形信息结构,最后呢,再用线性的“讲故事”的方式讲述出来。这就像极了我们在算法中用到的各种数据结构。
今天我们就深入来看看图和树这两种数据结构。我们先从图说起图就是一种非线性的数据结构。我们生活中有很多无序的网络组织都可以用图来表示比如社交网络我们的互联网通信、城市的道路、网站的链接。如果用我们前端开发的视角来看的话不同依赖资源本身也可以被看做是无序的而例如我们前端经常用到的webpack的模块加载功能就是一个在无序中建立秩序帮助我们厘清资源之间相关依赖关系的工具这种功能就可以通过拓扑排序来实现。我们可以先从图这种数据结构本身说起来一步步看下它是怎么做到的。
通过拓扑排序建立资源依赖
首先,我们来看下图的结构。
深入了解图的类型和结构
在上一节当中我们说过一个图就是由边edge 相连的节点node 组成的。如果延伸来看我们可以将通过一条线相连接的两个节点叫做相邻节点adjacent vertices。同时我们可以把一个节点连接的数量叫做度degree。一个边也可以加权weighted。一条路径path就是一组相邻节点的序列。
一条可以回到原点的路径是循环图cyclic graph。一个没有可以回到原点的路径的图被称作无环图acyclic graph。如果图之间的边是有指向的就会被称为是有向图directed graph反之如果图之间的边没有指向就被称之为无向图undirected graph。如果一个图是有向无环的就叫做我们上一讲提到过的有向无环图DAGdirected acyclic graph
一种用来存储图的方式是通过邻接矩阵adjacency matrix。如果一个图的相邻节点较少就被称为稀疏图sparse graph相反的如果一个图的相邻节点较多的话就被称为稠密图dense graph。对于稀疏图来说用邻接矩阵来存储可能就会造成资源的浪费因为这个矩阵中可能为了几个节点来维护一个较高的空间复杂度。同样邻接矩阵在支持节点的插入删除也具有劣势。所以另外一种用来存储图的方式就是通过邻接表adjacency list。这种数据结构既可以用数组也可以用链表、 哈希或者字典来表示,所以可以和邻接矩阵形成有效的互补。
对图的遍历有两种经典的方式一种是广度优先搜索BFSbreath first search另外一种是深度优先搜索DFSdepth 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和二叉查找树BSTbinary search tree。虽然它们写法类似但代表的是不同的意思。二叉树是最多只有2个子节点的树而二叉查找树是指一个左节点的值小于右节点的树。二叉树里面分为满二叉树full binary tree和完全二叉树complete binary tree。在二叉树的遍历中分为前中后三种顺序的遍历。下面我们依次来看一下。
在二叉查找树中有三种遍历的方式。第一种是中序遍历in-order traversal这种方法是对节点展开的从小到大的遍历它的遍历顺序是左、根、右第二种是前序遍历pre-order traversal这种方法是在先访问根节点再访问子节点它的遍历顺序是根、左、右第三种是后序遍历post-order traversal这种方法是在访问子节点后访问它的父节点它的遍历顺序是右、左、根。除了这三种以外还有层次遍历level order traversal这种方式就是上面我们讲到图的时候讲到的深度优先搜索DFSdepth first search的原理。
二叉查找树有一个问题就是当这个树的一个分支过深的时候在增加、减少和查找的时候可能会出现性能上的问题。所以在二叉查找树的基础上有了AVL树AVL treeAdelson-Velskii and Landis 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前我们可以先看看暴力BFBrute Force、BMBoyer-Moore、RKRabinKarp和KMPKnuthMorrisPratt 这几种用于字符串的匹配的算法。首先我们可以对比下暴力和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)\)因为每一个字符都是要被检查的。如果插入的字符串的数量是mn是最长的字符串的话它的空间复杂度是\(O(m \* n)\)。
所以从这里我们可以看出,对比其它常见的字符串查询,字典树更适合的是用来处理具有同样前缀的多个字符串。而在一个字符串中匹配一个单独的模式串,这种方式就不适合了,因为这样就会占用大量的空间内存。
总结
这一期我们就讲到这里。那么通过图和树这两种数据结构呢我们可以看出它们最大的特点就是可以映射出现实世界中相对无序和未知的结构。从具象中看我们可以发现它们可以用来解决web中的模块打包、路由等等。而抽象出来看我们也可以看到算法所解决的问题经常是在混沌和未知中建立秩序寻找出路。这对在杂乱的信息中梳理出秩序或者我们在面临人生选择时也是很有帮助的。同时我们看到在分析复杂度的问题时我们思考的维度也要随之扩展。除了前几讲中提到的时间、空间和最好、最坏情况下的复杂度外我们也要考虑预处理、不同应用场景下的复杂度。
思考题
今天的思考题是我们在讲到拓扑实现的时候用了数组和unshift。你觉得这种方式是最高效的吗从空间和时间复杂度或其它的角度的分析这里有没有什么优化空间
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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变成11变成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的原理学好算法不仅仅是纸上谈兵而是可以在工作和生活的方方面面帮助我们“运筹帷幄之中决胜千里之外”。
思考题
今天的思考题是,关于找零的问题,除了贪心和动态规划的实现,你能不能也用文中提到的递归和回溯来动手实现下找零。
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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也有很多后端的用例使用这些创建型模式你能分享下你的应用场景、遇到的相关问题和一些解决方案吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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模式我们也可以看出任何设计模式都不是绝对的而是可以互相结合形成新的模式来解决问题。
思考题
我们说响应式设计用到了很多函数式编程的思想,但是又不完全是函数式编程,你能说出它们的一些差别吗?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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来实现你能说说它是怎么实现的吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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 就是我们在一个函数操作完时把结果作为参数传递给另外一个函数的这样一个操作。在函数式编程中这种传递结果的方式称为连续传递样式CPScontinous 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吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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 的话,应该用过它的开发者工具中的时间旅行式调试,它可以将应用程序的状态向前、向后或移动到任意时间点。你知道这个功能的实现用到了今天学到的哪(些)种行为型设计模式吗?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!

View 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 }) {
...
}
说完了渲染属性下面我们再来看看高阶组件模式HOCHigher 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.statethis.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都是通过前端渲染开发的这样的模式不需要浏览器的刷新就可以让用户在不同的页面间进行切换。这样在带来了方便的同时也会造成性能上问题比如它的FCPFirst Contentful Paint首次内容绘制时间、 LCPLargest Contentful Paint最大内容绘制时间、TTITime to Interactive首次可交互时间 会比较长,遇到这种情况,通过初始最小化代码、预加载、懒加载、代码切割和缓存等手段,性能上的问题可以得到一些解决。
但是相比后端渲染前端渲染除了性能上的问题还会造成SEO的问题。通常为了解决SEO的问题一些网站会在SPA的基础上再专门生成一套供搜索引擎检索的后端页面。但是作为搜索的入口页面后端渲染的页面也会被访问到它最大的问题就是到第一字节的时间TTFB会比较长。
为了解决前端和后端渲染的问题静态渲染static rendering的概念便出现了。静态渲染使用的是一种预渲染pre-render的方式。也是说在服务器端预先渲染出可以在CDN上缓存的HTML页面当前端发起请求的时候直接将渲染好了的文件发送给后端通过这种方式就降低了TTFB。相比静态的页面内容而言JS的文件通常较小所以在静态渲染的情况下页面的FCP和TTI也不会太高。
静态渲染一般被称之为静态生成SSGstatic generation而由此又引出了静态渐进生成iSSGincremental 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来做相关组件或资源的加载。
除此之外另外一个值得了解的模式是PRPLPush Render, Pre-Cache, Lazy-load。PRPL模式的核心思想是在初始化的时候先推送渲染最小的初始化内容。之后在背后通过service worker缓存其它经常访问的路由相关的内容之后当用户想要访问相关内容时就不需要再请求而直接从缓存中懒加载相关内容。
PRPL的思想是如何实现的呢这里就要说到HTTP2的特点了。相比HTTP1.1HTTP2中提供的服务器推送可以一次把初始化所需要的资源以外的额外素材都一并推送给客户端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-virtualizedreact-window结合了上面讲到的摇树优化同时它也比react-virtualized更加轻量化。感兴趣的同学可以在Github上了解更多。
总结
通过今天的学习我们更系统化地了解了在前端强交互的场景下响应式编程中的几种设计模式。我们学到了可以通过Hooks来减少组件间嵌套关系更高效地建立数据、状态和行为上的联系。在加载和渲染的模式我们看到了为了提高展示和交互的速度降低资源的消耗如何渐进式地提供内容和交互。最后在性能优化模式中我们可以看到更多通过优化资源及节省算力的方式来提高性能的模式。
思考题
我们前面说到了通过Hooks可以减少嵌套那么你觉得这种方式可以直接取代上下文提供者context provider、渲染属性render props、高阶组件HOC的这些模式吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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是单核还是多核线程都是被全局解释器锁GILglobal 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的例子中传递的只是字符串这么看来可能也没什么问题。但是当我们要传递更复杂的数据结构时就会出现问题了。
比如如果我们需要传递的是下面这样一个带有参数的函数调用在这个时候我们需要先将函数调用转化为一个序列也就是一个对应的是我们的本地调用远程过程调用叫做PRCRemote 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也可以解决数据传递中性能的问题但是函数functionclass类是不能通过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来提高性能你知道它是如何做到的以及背后的原理吗?
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View File

@ -0,0 +1,168 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 性能如何理解JavaScript中的并行、并发
你好,我是石川。
在上一讲中我们初步介绍了并发和并行的概念对比了不同语言对多线程开发的支持。我们也通过postMessage学习了用信息传递的方式在主线程和Worker线程间实现交互。但是我们也发现了JavaScript对比其它语言在多线程方面还有不足似乎信息传递本身不能让数据在不同的线程中真正做到共享而只是互传拷贝的信息。
所以今天,我们再来看看如何能在信息互传的基础上,让数据真正地在多线程间共享和修改。不过更重要的是,这种修改是不是真的有必要呢。
SAB+Atomics模式
前面我们说过对象的数据结构在线程间是不能共享的。如果通过postMessage来做信息传递的话需要数据先被深拷贝。那有没有什么办法能让不同的线程同时访问一个数据源呢答案是有要做到数据的共享也就是内存共享我们就需要用到 SABSharedArrayBuffer和 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叫做快速用户空间互斥体futexfast 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你觉得可以实现对象的共享吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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 GCscavenger的作用就是回收新生代中生命周期较短的对象并且将生命周期较长的对象移动到老年代的半空间。年轻代空间里又包含对象区域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 GCScavenger用到的并行回收Scavenger Parallel。平行回收顾名思义就是垃圾回收的工作是在多线程间平行完成的。相比较并发它更容易处理因为在回收的时候主线程上的工作是全停顿的stop the world
V8用并行回收在工作线程间分配工作。每个线程会被分到一定数量的指针线程会根据指针把存活的对象疏散到对象空间。因为不同的任务都有可能通过不同的路径找到同一个对象并且做疏散和移动的处理所以这些任务是通过原子性的读写、对比和交换操作来避免竞争条件的。成功移动了对象的线程会再更新指针供其它线程参考更新。
早期V8所用到的是单线程的切尼半空间复制算法Cheneys semispace copying algorithm。后来把它改成多线程。与单线程一样这里的收集工作主要分3步扫描根、在年轻代中复制、向老年代晋升以及更新指针。
这三步是交织进行的。这是一个类似于霍尔斯特德半空间复制回收器Halsteads 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的垃圾回收是自动的但是我们在写代码的时候有没有什么“手工”优化内存的方法呢
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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次握手。显然这么操作是很费时的。
持久HTTPKeep 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 spriteJS或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中这类元数据是纯文本的形式每次传输会增加500800字节的开销而如果使用的是HTTP cookie有时还会增加到上千字节。
为了减少这样的开销并提高性能HTTP/2使用HPACK压缩格式压缩请求和响应标头的数据该格式使用了两种看上去很简单但强大的技术一是它允许通过静态霍夫曼代码对传输的头部字段进行编码从而减小了它们各自传输的大小二是它要求客户端和服务器维护和更新先前看到的标头字段的索引列表即建立共享压缩上下文可以作为更加高效编码的参考。
HPACK有静态和动态两个表。静态的是一些常用的标头元素动态表开始是空的后面根据实际的请求来增加。如果想更深入理解霍夫曼算法的同学也可以参考隔壁班黄清昊老师算法课中的《哈夫曼树HTTP2.0是如何更快传输协议头的?》。
请求优先级
因为上面我们看到HTTP/2中传递的信息可以被分割成多个单独的帧并且允许来自多个流的帧被复用那么考虑到客户和服务器端对帧进行交叉和传递的顺序HTTP/2标准允许每个流定义1256之间的权重和依赖性。
这里面流的依赖性和权重是一个“优先级树”型的结构这个树表达了客户端希望如何接收响应。反过来服务器可以通过控制CPU、内存和其他资源的分配使用这些信息来确定流处理的优先级并且一旦响应数据可用就可以分配带宽以最优的方式向客户端传递高优先级响应。
比如在上面的例1中流A和流B平级A的权重为8B的权重为4因此A应分配2/3可用资源B应获得剩余的1/3。在例2中C依赖于D因此D应该在C之前获得全部资源分配。以此类推在第3个例子中D应先于C获得全部资源分配C应先于A和B获得全部资源分配A应获得可用资源的2/3B应获得剩余的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互联网连接QUICQuick UDP Internet Connections是由谷歌于2012年推出的。它不再通过TCP而是通过用户数据报协议UDPUser Datagram Protocol进行传输。
同为传输层的协议相比较TCPUDP更加简便和快速。你可能会觉得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而起到了性能优化的效果那么你知道它是通过改进哪些算法来实现性能优化吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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可以通过什么来代替吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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有对不同类型的测试相对更广泛的支持。这也就避免了我们在不同的测试场景之间切换工具的情况。
除了功能性的测试,还有非功能性测试。非功能性测试又包含了性能测试、安全测试和辅助功能测试等等。这些,我们也会进一步在“测试三部曲”中的第三讲来看。
思考题
我们今天从红绿重构的角度了解了测试驱动的开发,这里我们主要看的是测试的深度(嵌套),除此之外,可能我们也要关注覆盖率和复杂度(圈数),那么你能分享下平时在开发中你是否有测试驱动开发的习惯?通常测试的覆盖率能达到多少呢?
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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({price21});
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
下面我们再来看看promise/then以及async/await的使用。同样如我们在异步时提到过的实际上async/await也是promise/then基语法糖的实现。我们也可以将await与resolve和reject结合起来使用。在下面的例子中我们可以看到当获取数据后我们可以通过对比期待的值来得到测试的结果。
// 例子1promise then
test('数据是价格为21', () => {
return fetchData().then(data => {
expect(data).toBe({price21});
});
});
// 例子2async await
test('数据是价格为21', async () => {
var data = await fetchData();
expect(data).toBe({price21});
});
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对比其它工具有着对快照和多线程的支持你能想到它们的使用场景和实现方式吗
欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View File

@ -0,0 +1,122 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 测试(三):非功能性测试
你好,我是石川。
上节课我们学习了功能类测试。今天我们来看一下非功能性测试中的性能、安全和辅助功能测试。对于一个Web应用而言性能测试的复杂程度并不低于后端或端到端的测试。导致前端性能测试复杂度很高的主要原因是影响Web应用性能的因素有很多并且很多是应用本身不完全可控的。所以今天我们重点来看一下关于性能测试都有哪些指标、影响性能的因素和性能调优的方式。
性能测试
在任何测试前我们都需要对要达到的目标有清晰的认识针对性能测试也不例外。所以首先我们要来看看对于Web应用来说都有哪些性能指标。
性能指标
之前我们说过JS虚机开发中做内存管理和优化的时候会关注浏览器和应用的流畅度smoothness和响应度responsiveness。其实对于JS应用的开发者来说也可以参考类似的 Rail 指标。Railresponse, amination, idleload是响应、动效、空闲和加载这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。那么你知道如何通过浏览器提供的工具或独立的开发者工具来分析、排查和解决多线程开发中的性能问题吗
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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而是自行创建了一个新的解析器呢
欢迎在留言区分享你的看法、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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遵循的是哪种思想吗你觉得哪种思想更适合实现
欢迎在留言区分享你的看法、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View File

@ -0,0 +1,100 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 包管理和分发通过NPM做包的管理和分发
你好,我是石川。
在前面几讲中我们看到无论是响应式编程框架React还是测试用的Jest、Puppeteer工具亦或是做代码检查和样式优化的ESLint、Prettier工具都离不开第三方库而我们在之前的例子中都是通过NPM下载和安装工具的。
所以今天我们就来深入了解下NPM以及包的管理和发布。
包的发布
NPMNode 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 吗?除了我们讲到的方法外,你还能想到其他的安全措施吗?
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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 我们也可以解决功能兼容的问题,那么你知道它俩使用场景上的区别吗?
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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 模版引擎有什么区别吗?各自的优劣势是怎样的?
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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 的转译,我们可以在提前用到新功能的同时,保证较老版本浏览器的支持;
第二个方法,就是使用 PolyfillPolyfill 作为一个插件,它提供了较新浏览器的功能,但也提供了较旧版本的功能。
前面,我们讲过了代码转译的工具 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 的场景吗?你主要使用它来解决哪些问题呢?
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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。微服务的到来对业务架构、软件开发和软件工程都有着不同程度的影响。
从业务架构的角度来看它是基于域驱动的设计这样的设计更多关注的是面向业务对象而不是面向过程的设计。第二个是从软件开发的角度传统的服务开发模式大多是基于贫血的域模型ADMAnemic Domain Model的开发而随着微服务和 DDD 的兴起逐渐转为了充血的域模型RDMRich 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 会结合 CDNContent Delivery Network一起使用通过缓存加快页面和资源加载时间。当然这种经典的 CMS 方法至今仍被广泛使用,例如现在使用 WordPress 创建个人博客的人还有很多。CMS 工具流行的一个原因是,选择用 CMS 来开发主要是着重信息更新,不需要大量的业务逻辑交互的页面。
随着 Ajax 的兴起,以及后来的 HTML5 和 CSS3 技术的出现网站的互动性越来越强丰富的互联网应用程序也不断涌现。由于更多的用户交互SPA单页应用程序的技术就随之兴起了在 Web 开发中得到了越来越广泛的应用。虽然在单页应用程序中,仍然可以使用 CMS但它的功能将仅限于管理内容而不是创建HTML页面。在这种情况下前端应用程序承载了页创建和展示的任务而 CMS 只负责内容的提供。通常这样的 CMS 被称为无头 CMSheadless CMS
SPA 不仅限于 PC 端,还可以用于移动端和其他嵌入式系统,并且可能会根据客户端类型对内容进行一些更改,在这种情况下,还可能需要添加一个 BFF 的适配层。当然 BFF 的作用不仅在适配,它也可以用于 API 层的复合或聚合、服务的认证或授权,或者在遗留系统之上构建微服务。
单页应用程序的另一个问题是搜索引擎优化。搜索引擎通常基于 URL 链接搜索内容。然而,对于单页应用程序,搜索引擎不再能够轻松找到页面,因为当用户从一个部分导航到另一个部分时,没有 URL 更新。谷歌开发了强大的算法,甚至可以抓取 SPA 页面。然而,谷歌并不是世界上唯一使用的搜索引擎,仍然有许多搜索引擎无法抓取服务器端渲染和客户端渲染的 HTML 页面。
针对这个问题,目前最常用的解决方案是同时使用客户端和服务器端渲染,这意味着前端和后端将共享相同的模板文件,并使用单独的模板引擎来渲染包含数据的页面。这种与 Node.js 结合的模式被称为同构 JavaScriptisomorphic 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 的注册和发现,路由以及生命周期的管理。而且越是去中心化,我们越是需要团队间有一套“共识机制”保证开发和部署流程的管理。但是这样带来的好处是,我们可以在子团队间有更大的开发自由,同时,在规模化的情况下,可以保证协作的效率。
思考题
在微前端中,每个组件都是独立封装的,你知道如何实现它们之间的通信吗?
欢迎在留言区分享你的经验、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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 一种核心技术,满足了前中后台的开发需求,也等于起到了降本增效的作用。但除此之外,你还能想到它的其它优势或者一些短板吗?
欢迎在留言区分享你的观点、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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("&", "&amp;")
.replace("'", "&#39;"));
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 &amp; 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 在这种使用场景下算不算是一种元编程呢?
欢迎在留言区分享你的观点、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。我们下节课再见!

View 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 这门语言更好地拥抱不确定性。
最后,我还给你准备了一份毕业问卷,希望你能花两三分钟填写一下,非常期待能听到你对这门课的反馈。

View 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对并发问题的深刻认识。可惜这个设计的思想目前并没有相关的论文所以只能自己琢磨了。
分工、同步和互斥的全景图,是我对并发问题的个人总结,不一定正确,但是可以帮助我快速建立解决并发问题的思路,梳理并发编程的知识,加深认识。我将其分享给你,希望对你也有用。
对于某个具体的技术,我建议你探索它背后的理论本质,理论的应用面更宽,一项优秀的理论往往在多个语言中都有体现,在多个不同领域都有应用。所以探求理论本质,既能加深对技术本身的理解,也能拓展知识深度和广度,这是个一举多得的方法。这方面,希望我们一起探讨,共同进步。
欢迎在留言区跟我分享你的经历与想法。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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并发让你的知识更成体系融会贯通。最终让你能够得心应手地解决各种并发难题同时将这些知识用于其他编程语言让你的一分辛劳三分收获。
下面就是这个专栏的目录,你可以快速了解下整个专栏的知识结构体系。
当然,我们要坚持下去,不能三天打鱼两天晒网,因为滴水穿石非一日之功。
很多人都说学习是反人性的,开始容易,但是长久的坚持却很难。这个我也认同,我面试的时候,就经常问候选人一个问题:“工作中,有没有一件事你自己坚持了很久,并且从中获益?”如果候选人能够回答出来,那会是整个面试的加分项,因为我觉得,坚持真是一个可贵的品质,一件事情,有的人三分热度,而有的人,一做就能做一年,或者更久。你放长到时间的维度里看,这两种人,最后的成就绝对是指数级的差距。
我希望你能和我坚持下来,我们一起学习,一起交流,遇到问题不是简单地抱怨和逃避,而是努力探寻答案与解决方法。这一次,就让我们一起来坚持探索并发编程的奥秘,体会探索知识的乐趣。今天的文章是开篇词,我们的主菜很快就来,如果可以的话,还请在留言区中做个自我介绍,和我聊聊你目前的工作、学习情况,以及你在并发编程方面的学习痛点,方便我在后面针对性地给你讲解,这样,我们可以彼此了解。
最后,感谢你对我的信任,我定会努力实现完美交付。

View 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=6b=7”编译器优化后可能变成“b=7a=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型变量进行加减操作存在并发隐患到底是不是这样呢现在相信你一定能分析出来。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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中指的就是synchronizedsynchronized是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-BeforeB事件发生的这个就是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

View 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;
}
}
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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作为互斥锁你觉得是否可以呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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的锁对于T1this是账户A而T2获得了账户B的锁对于T2this是账户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)有没有性能优势呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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()方法都能让当前线程挂起一段时间,那它们的区别是什么?现在你也试着回答一下吧。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,153 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 安全性、活跃性以及性能问题
通过前面六篇文章,我们开启了一个简单的并发旅程,相信现在你对并发编程需要注意的问题已经有了更深入的理解,这是一个很大的进步,正所谓只有发现问题,才能解决问题。但是前面六篇文章的知识点可能还是有点分散,所以是时候将其总结一下了。
并发编程中我们需要注意的问题有很多,很庆幸前人已经帮我们总结过了,主要有三个方面,分别是:安全性问题、活跃性问题和性能问题。下面我就来一一介绍这些问题。
安全性问题
相信你一定听说过类似这样的描述:这个方法不是线程安全的,这个类不是线程安全的,等等。
那什么是线程安全呢其实本质上就是正确性而正确性的含义就是程序按照我们期望的执行不要让我们感到意外。在第一篇《可见性、原子性和有序性问题并发编程Bug的源头》中我们已经见识过很多诡异的Bug都是出乎我们预料的它们都没有按照我们期望的执行。
那如何才能写出线程安全的程序呢第一篇文章中已经介绍了并发Bug的三个主要源头原子性问题、可见性问题和有序性问题。也就是说理论上线程安全的程序就要避免出现原子性问题、可见性问题和有序性问题。
那是不是所有的代码都需要认真分析一遍是否存在这三个问题呢当然不是其实只有一种情况需要存在共享数据并且该数据会发生变化通俗地讲就是有多个线程会同时读写同一数据。那如果能够做到不共享数据或者数据状态不发生变化不就能够保证线程的安全性了嘛。有不少技术方案都是基于这个理论的例如线程本地存储Thread Local StorageTLS、不变模式等等后面我会详细介绍相关的技术方案是如何在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);
}
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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模型里面增加了超时参数你觉得这个参数有必要吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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();
}
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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计算[125亿)线程B计算[25亿50亿)线程C计算[5075亿)线程D计算[75亿100亿],之后汇总,那么理论上应该比一个线程计算[1100亿]快将近4倍响应时间能够降到25%。一个线程对于4核的CPUCPU的利用率只有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你觉得这个经验值合理吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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->CA调用BB调用C在运行时会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间称为栈帧每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时会创建新的栈帧并压入调用栈当方法返回时对应的栈帧就会被自动弹出。也就是说栈帧和方法是同生共死的。
调用栈结构
利用栈结构来支持方法调用这个方案非常普遍以至于CPU里内置了栈寄存器。虽然各家编程语言定义的方法千奇百怪但是方法的内部执行原理却是出奇的一致都是靠栈结构解决的。Java语言虽然是靠虚拟机解释执行的但是方法的调用也是利用栈结构解决的。
局部变量存哪里?
我们已经知道了方法间的调用在CPU眼里是怎么执行的但还有一个关键问题方法内的局部变量存哪里
局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。此时你应该会想到调用栈的栈帧,调用栈的栈帧就是和方法同生共死的,所以局部变量放到调用栈里那儿是相当的合理。事实上,的确是这样的,局部变量就是放到了调用栈里。于是调用栈的结构就变成了下图这样。
保护局部变量的调用栈结构
这个结论相信很多人都知道因为学Java语言的时候基本所有的教材都会告诉你 new 出来的对象是在堆里,局部变量是在栈里,只不过很多人并不清楚堆和栈的区别,以及为什么要区分堆和栈。现在你应该很清楚了,局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。
调用栈与线程
两个线程可以同时用不同的参数调用相同的方法那调用栈和线程之间是什么关系呢答案是每个线程都有自己独立的调用栈。因为如果不是这样那两个线程就互相干扰了。如下面这幅图所示线程A、B、C每个线程都有自己独立的调用栈。
线程与调用栈的关系图
现在让我们回过头来再看篇首的问题Java方法里面的局部变量是否存在并发问题现在你应该很清楚了一点问题都没有。因为每个线程都有自己的调用栈局部变量保存在线程各自的调用栈里面不会共享所以自然也就没有并发问题。再次重申一遍没有共享就没有伤害。
线程封闭
方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做线程封闭,比较官方的解释是:仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。
采用线程封闭技术的案例非常多例如从数据库连接池里获取的连接Connection在JDBC规范里并没有要求这个Connection必须是线程安全的。数据库连接池通过线程封闭技术保证一个Connection一旦被一个线程获取之后在这个线程关闭Connection之前的这段时间里不会再分配给其他线程从而保证了Connection不会有并发问题。
总结
调用栈是一个通用的计算机概念所有的编程语言都会涉及到Java调用栈相关的知识我并没有花费很大的力气去深究但是靠着那点C语言的知识稍微思考一下基本上也就推断出来了。工作了十几年我发现最近几年和前些年最大的区别是很多技术的实现原理我都是靠推断然后看源码验证而不是像以前一样纯粹靠看源码来总结了。
建议你也多研究原理性的东西、通用的东西,有这些东西之后再学具体的技术就快多了。
课后思考
常听人说,递归调用太深,可能导致栈溢出。你思考一下原因是什么?有哪些解决方案呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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并发编程实战》这本书的第三章《对象的共享》、第四章《对象的组合》全面地介绍了如何构建线程安全的对象你可以拿来深入地学习。

View 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安全编码标准》这本书如果你英文足够好也可以参考这份文档。
最后总结一句,学好理论有思路,关注细节定成败。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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规则
顺序性规则对于线程T1value+=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
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,191 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 Lock和ConditionDubbo如何用管程实现异步转同步
在上一篇文章中我们讲到Java SDK并发包里的Lock有别于synchronized隐式锁的三个特性能够响应中断、支持超时和非阻塞地获取锁。那今天我们接着再来详细聊聊Java SDK并发包里的ConditionCondition实现了管程模型里面的条件变量。
在《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(),你来分析一下,这样做是否合理呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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是否可以呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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利用率很低你怀疑有同学一不小心写出了读锁升级写锁的方案那你该如何验证自己的怀疑呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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);
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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的回调函数我们使用了一个固定大小的线程池你觉得是否有必要呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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%,你觉得该如何验证你的猜测呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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));
}
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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这样没有业务含义。而很多情况下为了便于诊断问题都需要给线程赋予一个有意义的名字那你知道有哪些办法可以给线程池里的线程指定名字吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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对象是ff.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和ft2ft1完成洗水壶、烧开水、泡茶的任务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);
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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();
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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;
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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并行计算框架是否能够提高性能呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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&ConditionDubbo如何用管程实现异步转同步》的思考题是关于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并发工具类内容繁杂熟练使用是需要一个过程的而且需要多加实践。希望你学完这个模块之后遇到并发问题时最起码能知道用哪些工具可以解决。至于工具使用的细节和最佳实践我总结的也只是我认为重要的。由于每个人的思维方式和编码习惯不同也许我认为不重要的恰恰是你的短板所以这部分内容更多地还是需要你去实践在实践中养成良好的编码习惯不断纠正错误的思维方式。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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.8valueOf()方法就用到了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用锁alB用锁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;
}
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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)、aufsadvanced 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呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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是每个线程拥有的变量VThreadLocal内部持有这样的一个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其类型就是ThreadLocalMapThreadLocalMap的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的事务管理器呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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和ConditionDubbo如何用管程实现异步转同步》中曾介绍过的异步转同步问题吗仔细分析的确是这样不过在那一篇文章中我们只是介绍了最终方案让你知其然但是并没有介绍这个方案是如何设计出来的今天咱们再仔细聊聊这个问题让你知其所以然遇到类似问题也能自己设计出方案来。
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;
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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();
}
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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那有什么办法可以快速解决呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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) {
}
});
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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;
......
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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) {}
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,165 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 设计模式模块热点问题答疑
多线程设计模式是前人解决并发问题的经验总结,当我们试图解决一个并发问题时,首选方案往往是使用匹配的设计模式,这样能避免走弯路。同时,由于大家都熟悉设计模式,所以使用设计模式还能提升方案和代码的可理解性。
在这个模块我们总共介绍了9种常见的多线程设计模式。下面我们就对这9种设计模式做个分类和总结同时也对前面各章的课后思考题做个答疑。
避免共享的设计模式
Immutability模式、Copy-on-Write模式和线程本地存储模式本质上都是为了避免共享只是实现手段不同而已。这3种设计模式的实现都很简单但是实现过程中有些细节还是需要格外注意的。例如使用Immutability模式需要注意对象属性的不可变性使用Copy-on-Write模式需要注意性能问题使用线程本地存储模式需要注意异步执行问题。所以每篇文章最后我设置的课后思考题的目的就是提醒你注意这些细节。
《28 | Immutability模式如何利用不变性解决并发问题》的课后思考题是讨论Account这个类是不是具备不可变性。这个类初看上去属于不可变对象的中规中矩实现而实质上这个实现是有问题的原因在于StringBuffer不同于StringStringBuffer不具备不可变性通过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多线程设计模式》这本书来深入学习这个模块这是一本不错的并发编程入门书籍虽然重点是讲解设计模式但是也详细讲解了设计模式中涉及到的方方面面的基础知识而且深入浅出非常推荐入门的同学认真学习一下。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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构建了一个积分函数来解决这个问题如果你感兴趣可以继续深入研究。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,145 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
39 案例分析高性能网络应用框架Netty
Netty是一个高性能网络应用框架应用非常普遍目前在Java领域里Netty基本上成为网络程序的标配了。Netty框架功能丰富也非常复杂今天我们主要分析Netty框架中的线程模型而线程模型直接影响着网络程序的性能。
在介绍Netty的线程模型之前我们首先需要把问题搞清楚了解网络编程性能的瓶颈在哪里然后再看Netty的线程模型是如何解决这个问题的。
网络编程性能的瓶颈
在《33 | Thread-Per-Message模式最简单实用的分工方法》中我们写过一个简单的网络程序echo采用的是阻塞式I/OBIO。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里还提供了非阻塞式NIOAPI利用非阻塞式API就能够实现一个线程处理多个连接了。那具体如何实现呢现在普遍都是采用Reactor模式包括Netty的实现。所以要想理解Netty的实现接下来我们就需要先了解一下Reactor模式。
Reactor模式
下面是Reactor模式的类结构图其中Handle指的是I/O句柄在Java网络编程里它本质上就是一个网络连接。Event Handler很容易理解就是一个事件处理器其中handle_event()方法处理I/O事件也就是每个Event Handler处理一个I/O Handleget_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网络编程》。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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篇文章里我跟着学会了如何用多线程并行操作来优化程序执行时间以及如何用线程通信来让程序执行得更高效。
就这样,从头到尾跟下来后,我收获颇丰!
现在,我也尝试着把我学到的知识点用于项目中,不断优化自己的代码。如今我开始每做一个需求,都会对其资源消耗、时间损耗和并发安全多进行一步思考和优化。这些都为我的项目成功上线起到了重要的保障作用,我的同事也开始夸奖我并发方面的表现突出。这是我最开心和欣慰的地方。
由此,我想感谢宝令老师,是发自内心且由衷地感谢。感谢老师这几个月的一直陪伴,感谢老师分享的知识让我向着理想更进了一步,感谢老师怀揣着对技术的执着之心激励我初心依旧。
宝令回信:
你这么快就能在工作中熟练使用了,这是我最开心和欣慰的地方。学会怎么思考并且在工作中实践,进步一定很快。祝在工作中更上一层楼!

View 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独占一个缓存行。下面的示例代码出自DisruptorSequence 对象中的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。不过避免伪共享是以牺牲内存为代价的所以具体使用的时候还是需要仔细斟酌。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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提供的SynchronousQueueSynchronousQueue主要用于线程之间传递数据。
//用于存储所有的数据库连接
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做一次预分配避免直接竞争共享资源非常适合池化资源的分配。
在实际工作中,我们遇到的并发问题千差万别,这时选择合适的并发数据结构就非常重要了。当然能选对的前提是对特定场景的并发特性有深入的了解,只有了解到无谓的性能消耗在哪里,才能对症下药。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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模型先有个感官的印象。
在下面的示例代码中我们首先创建了一个ActorSystemActor不能脱离ActorSystem存在之后创建了一个HelloActorAkka中创建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模型也是有成本的。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,247 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
43 软件事务内存:借鉴数据库的并发经验
很多同学反馈说工作了挺长时间但是没有机会接触并发编程实际上我们天天都在写并发程序只不过并发相关的问题都被类似Tomcat这样的Web服务器以及MySQL这样的数据库解决了。尤其是数据库在解决并发问题方面可谓成绩斐然它的事务机制非常简单易用能甩Java里面的锁、原子类十条街。技术无边界很显然要借鉴一下。
其实很多编程语言都有从数据库的事务管理中获得灵感并且总结出了一个新的并发解决方案软件事务内存Software Transactional Memory简称STM。传统的数据库事务支持4个特性原子性Atomicity、一致性Consistency、隔离性Isolation和持久性Durability也就是大家常说的ACIDSTM由于不涉及到持久化所以只支持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这个类负责完成事务内的读写操作读写操作委托给了接口TxnTxn代表的是读写操作所在的当前事务 内部持有的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内部有两个MapinTxnMap用于保存当前事务中所有读写的数据的快照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 这篇博文,里面讲到了如何优化,你可以尝试学习下。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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程序员也可以使用协程来解决并发问题了。
计算机里很多面向开发人员的技术,大多数都是在解决一个问题:易用性。协程作为一项并发编程技术,本质上也不过是解决并发工具的易用性问题而已。对于易用性,我觉得最重要的就是要适应我们的思维模式,在工作的前几年,我并没有怎么关注它,但是最近几年思维模式已成为我重点关注的对象。因为思维模式对工作的很多方面都会产生影响,例如质量。
一个软件产品是否能够活下去,从质量的角度看,最核心的就是代码写得好。那什么样的代码是好代码呢?我觉得,最根本的是可读性好。可读性好的代码,意味着大家都可以上手,而且上手后不会大动干戈。那如何让代码的可读性好呢?很简单,换位思考,用大众、普通的思维模式去写代码,而不是炫耀自己的各种设计能力。我觉得好的代码,就像人民的艺术一样,应该是为人民群众服务的,只有根植于广大群众之中,才有生命力。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@ -0,0 +1,133 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
45 CSP模型Golang的主力队员
Golang是一门号称从语言层面支持并发的编程语言支持并发是Golang一个非常重要的特性。在上一篇文章《44 | 协程更轻量级的线程》中我们介绍过Golang支持协程协程可以类比Java中的线程解决并发问题的难点就在于线程协程之间的协作。
那Golang是如何解决协作问题的呢
总的来说Golang提供了两种不同的方案一种方案支持协程之间以共享内存的方式通信Golang提供了管程和原子类来对协程进行同步控制这个方案与Java语言类似另一种方案支持协程之间以消息传递Message-Passing的方式通信本质上是要避免共享Golang的这个方案是基于CSPCommunicating Sequential Processes模型实现的。Golang比较推荐的方案是后者。
什么是CSP模型
我们在《42 | Actor模型面向对象原生的并发模型》中介绍了Actor模型Actor模型中Actor之间就是不能共享内存的彼此之间通信只能依靠消息传递的方式。Golang实现的CSP模型和Actor模型看上去非常相似Golang程序员中有句格言“不要以共享内存方式通信要以通信方式共享内存Dont 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中协程之间通信推荐的是使用channelchannel你可以形象地理解为现实世界里的管道。另外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并没有经过广泛的生产环境检验所以并不建议你在生产环境中使用。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View 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 及大家,能坚持看完我的专栏,并能运用在实际工作中,真的值了。也欢迎大家把这篇文章分享给朋友,相互学习,互相激励。

View File

@ -0,0 +1,25 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 十年之后,初心依旧
曾经有个特别好的朋友跟我说过“你挺适合当老师的”其实适不适合并不一定但是好为人师是一定的。到这里我已经分享了45篇的技术文章估计你也看累了、听累了需要些时间好好消化消化。所以最后咱们轻松一下吧聊聊人生、聊聊理想正好我也和你聊聊我那些“不堪回首的往事”。
我曾经搞过5年的ERP其间我是很想在这条路上一直走下去但在这个行业摸爬滚打了几年之后我发现这个行业里懂业务比懂技术更重要。于是为了提高业务水平我就去搞注册会计师了但在我还没有搞定它的时候我突然发现自己竟然失业了。这个时候我才意识到选择拼搏于细分行业里的夕阳产业是多么愚蠢。选择永远比努力更重要。
可笑的是我们选择的,往往不是我们期望的那样。后来我阴错阳差去了一家央企,传统观点认为这里和养老院是对门儿,可实际上,在“养老院对门儿”的这三年多,是我成长最快的三年,包括技术。这三年属于被“骂”的最多的三年,做的东西被同行“骂”,汇报被领导“骂”,被“骂”的多了,渐渐就意识到自己的问题了。找到自己的问题,才是最重要的。
一哥们儿曾有过一段经典的总结:所有的失败都可以归结为“错估了形势,低估了敌人,高估了自己”。人,总是高估了自己,显然,我也是。很多时候,我也会一不小心就高估了自己,而且还一点都意识不到。感谢佛家经典《金刚经》,虽说到现在我也没有把它抄完,但是抄到不到一半的时候,我已经深深认识到自己是多么的浅薄与狂妄了。驱除虚妄,才能进步。
搞技术的瓶颈在哪里呢每个人资质、机遇不同其实没有必要强求。我也曾经兴趣广泛大学时还买过全英文的《Intel微处理器》搬了几次家都没舍得扔前两年终于扔掉了纯粹是浪费时间和空间。有时我们得承认不是随便一个领域我们都能干得很深入的实际场景和资质都很重要。拿不动的东西越早放弃越好做了减法才能做加法生也有涯该放就放。
工作十年,很多人已经在不同的轨道上了,有些人选择了做管理,有些人选择了创业,只有很少的人在搞技术。十年,很多面具下的脸都已千疮百孔,有些人摘下面具很丑,有些人摘下面具很怪,只有很少的人摘下面具你还认得。事实证明,你不认得的,基本都已落马;你还认得的,基本都混得不错。正所谓,路遥知马力,日久见人心。简单做人,挺好。
工作了十多年,最值得骄傲的是,更加相信善良。最后也祝你十年之后,初心依旧。
](https://jinshuju.net/f/9W7ghF)

View 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知识体系。

View 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 Weve Been Nonchalantly Moving Away From Exception Handling。
我们从性能角度来审视一下Java的异常处理机制这里有两个可能会相对昂贵的地方
try-catch代码段会产生额外的性能开销或者换个角度说它往往会影响JVM对代码进行优化所以建议仅捕获有必要的代码段尽量不要一个大的try包住整段的代码与此同时利用异常控制代码流程也不是一个好主意远比我们通常意义上的条件语句if/else、switch要低效。
Java每实例化一个Exception都会对当时的栈进行快照这是一个相对比较重的操作。如果发生的非常频繁这个开销可就不能被忽略了。
所以对于部分追求极致性能的底层类库有种方式是尝试创建不进行栈快照的Exception。这本身也存在争议因为这样做的假设在于我创建异常时知道未来是否需要堆栈。问题是实际上可能吗小范围或许可能但是在大规模项目中这么做可能不是个理智的选择。如果需要堆栈但又没有收集这些信息在复杂情况下尤其是类似微服务这种分布式系统这会大大增加诊断的难度。
当我们的服务出现反应变慢、吞吐量下降的时候检查发生最频繁的Exception也是一种思路。关于诊断后台变慢的问题我会在后面的Java性能基础模块中系统探讨。
今天我从一个常见的异常处理概念问题简单总结了Java异常处理的机制。并结合代码分析了一些普遍认可的最佳实践以及业界最新的一些异常使用共识。最后我分析了异常性能开销希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗可以思考一个问题对于异常处理编程不同的编程范式也会影响到异常处理策略比如现在非常火热的反应式编程Reactive Stream因为其本身是异步、基于事件机制的所以出现异常情况决不能简单抛出去另外由于代码堆栈不再是同步调用那种垂直的结构这里的异常处理和日志需要更加小心我们看到的往往是特定executor的堆栈而不是业务方法调用关系。对于这种情况你有什么好的办法吗
请你在留言区分享一下你的解决方案,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View 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机制仍然是有缺陷的你有什么更好的建议吗
请你在留言区写写你的建议,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View 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去保障对象不被意外回收希望对你有所帮助。
一课一练
关于今天我们讨论的题目你做到心中有数了吗?给你留一道练习题,你能从自己的产品或者第三方类库中找到使用各种引用的案例吗?它们都试图解决什么问题?
请你在留言区写写你的答案,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享出去,或许你能帮到他。

View 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底层都是利用可修改的charJDK 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