开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

网站页面性能优化的34条黄金守则

发表于 2021-03-09

雅虎团队经验:网站页面性能优化的34条黄金守则

1、尽量减少HTTP请求次数

终端用户响应的时间中,有80%用于下载各项内容。这部分时间包括下载页面中的图像、样式表、脚本、Flash等。通过减少页面中的元素可以减少HTTP请求的次数。这是提高网页速度的关键步骤。

减少页面组件的方法其实就是简化页面设计。那么有没有一种方法既能保持页面内容的丰富性又能达到加快响应时间的目的呢?这里有几条减少HTTP请求次数同时又可能保持页面内容丰富的技术。

合并文件是通过把所有的脚本放到一个文件中来减少HTTP请求的方法,如可以简单地把所有的CSS文件都放入一个样式表中。当脚本或者样式表在不同页面中使用时需要做不同的修改,这可能会相对麻烦点,但即便如此也要把这个方法作为改善页面性能的重要一步。

CSS Sprites是减少图像请求的有效方法。把所有的背景图像都放到一个图片文件中,然后通过CSS的background-image和background-position属性来显示图片的不同部分;

图片地图是把多张图片整合到一张图片中。虽然文件的总体大小不会改变,但是可以减少HTTP请求次数。图片地图只有在图片的所有组成部分在页面中是紧挨在一起的时候才能使用,如导航栏。确定图片的坐标和可能会比较繁琐且容易出错,同时使用图片地图导航也不具有可读性,因此不推荐这种方法;

内联图像是使用data:URL scheme的方法把图像数据加载页面中。这可能会增加页面的大小。把内联图像放到样式表(可缓存)中可以减少HTTP请求同时又避免增加页面文件的大小。但是内联图像现在还没有得到主流浏览器的支持。

减少页面的HTTP请求次数是你首先要做的一步。这是改进首次访问用户等待时间的最重要的方法。如同Tenni Theurer的他的博客Browser Cahe Usage - Exposed!中所说,HTTP请求在无缓存情况下占去了40%到60%的响应时间。让那些初次访问你网站的人获得更加快速的体验吧!

2、减少DNS查找次数

域名系统(DNS)提供了域名和IP的对应关系,就像电话本中人名和他们的电话号码的关系一样。当你在浏览器地址栏中输入www.dudo.org时,DNS解析服务器就会返回这个域名对应的IP地址。DNS解析的过程同样也是需要时间的。一般情况下返回给定域名对应的IP地址会花费20到120毫秒的时间。而且在这个过程中浏览器什么都不会做直到DNS查找完毕。

 缓存DNS查找可以改善页面性能。这种缓存需要一个特定的缓存服务器,这种服务器一般属于用户的ISP提供商或者本地局域网控制,但是它同样会在用户使用的计算机上产生缓存。DNS信息会保留在操作系统的DNS缓存中(微软Windows系统中DNS Client Service)。大多数浏览器有独立于操作系统以外的自己的缓存。由于浏览器有自己的缓存记录,因此在一次请求中它不会受到操作系统的影响。


Internet Explorer默认情况下对DNS查找记录的缓存时间为30分钟,它在注册表中的键值为DnsCacheTimeout。Firefox对DNS的查找记录缓存时间为1分钟,它在配置文件中的选项为network.dnsCacheExpiration(Fasterfox把这个选项改为了1小时)。


当客户端中的DNS缓存都为空时(浏览器和操作系统都为空),DNS查找的次数和页面中主机名的数量相同。这其中包括页面中URL、图片、脚本文件、样式表、Flash对象等包含的主机名。减少主机名的数量可以减少DNS查找次数。


减少主机名的数量还可以减少页面中并行下载的数量。减少DNS查找次数可以节省响应时间,但是减少并行下载却会增加响应时间。我的指导原则是把这些页面中的内容分割成至少两部分但不超过四部分。这种结果就是在减少DNS查找次数和保持较高程度并行下载两者之间的权衡了。

3、避免跳转

跳转是使用301和302代码实现的。下面是一个响应代码为301的HTTP头:

HTTP/1.1 301 Moved Permanently

Location: example.com/newuri

Content-Type: text/html

浏览器会把用户指向到Location中指定的URL。头文件中的所有信息在一次跳转中都是必需的,内容部分可以为空。不管他们的名称,301和302响应都不会被缓存除非增加一个额外的头选项,如Expires或者Cache-Control来指定它缓存。元素的刷新标签和JavaScript也可以实现URL的跳转,但是如果你必须要跳转的时候,最好的方法就是使用标准的3XXHTTP状态代码,这主要是为了确保“后退”按钮可以正确地使用。

但是要记住跳转会降低用户体验。在用户和HTML文档中间增加一个跳转,会拖延页面中所有元素的显示,因为在HTML文件被加载前任何文件(图像、Flash等)都不会被下载。


有一种经常被网页开发者忽略却往往十分浪费响应时间的跳转现象。这种现象发生在当URL本该有斜杠(/)却被忽略掉时。例如,当我们要访问[astrology.yahoo.com/astrology](http://astrology.yahoo.com/astrology) 时,实际上返回的是一个包含301代码的跳转,它指向的是[astrology.yahoo.com/astrology/](http://astrology.yahoo.com/astrology/)  (注意末尾的斜杠)。在Apache服务器中可以使用Alias 或者 mod\_rewrite或者the DirectorySlash来避免。


连接新网站和旧网站是跳转功能经常被用到的另一种情况。这种情况下往往要连接网站的不同内容然后根据用户的不同类型(如浏览器类型、用户账号所属类型)来进行跳转。使用跳转来实现两个网站的切换十分简单,需要的代码量也不多。尽管使用这种方法对于开发者来说可以降低复杂程度,但是它同样降低用户体验。一个可替代方法就是如果两者在同一台服务器上时使用Alias和mod\_rewrite和实现。如果是因为域名的不同而采用跳转,那么可以通过使用Alias或者mod\_rewirte建立CNAME(保存一个域名和另外一个域名之间关系的DNS记录)来替代。

4、可缓存的AJAX

Ajax经常被提及的一个好处就是由于其从后台服务器传输信息的异步性而为用户带来的反馈的即时性。但是,使用Ajax并不能保证用户不会在等待异步的JavaScript和XML响应上花费时间。在很多应用中,用户是否需要等待响应取决于Ajax如何来使用。例如,在一个基于Web的Email客户端中,用户必须等待Ajax返回符合他们条件的邮件查询结果。记住一点,“异步”并不异味着“即时”,这很重要。

为了提高性能,优化Ajax响应是很重要的。提高Ajxa性能的措施中最重要的方法就是使响应具有可缓存性,具体的讨论可以查看Add an Expires or a Cache-Control Header。其它的几条规则也同样适用于Ajax:   

Gizp压缩文件

减少DNS查找次数

精简JavaScript

避免跳转

配置ETags

让我们来看一个例子:一个Web2.0的Email客户端会使用Ajax来自动完成对用户地址薄的下载。如果用户在上次使用过Email web应用程序后没有对地址薄作任何的修改,而且Ajax响应通过Expire或者Cacke-Control头来实现缓存,那么就可以直接从上一次的缓存中读取地址薄了。必须告知浏览器是使用缓存中的地址薄还是发送一个新的请求。这可以通过为读取地址薄的Ajax URL增加一个含有上次编辑时间的时间戳来实现,例如,&t=11900241612等。如果地址薄在上次下载后没有被编辑过,时间戳就不变,则从浏览器的缓存中加载从而减少了一次HTTP请求过程。如果用户修改过地址薄,时间戳就会用来确定新的URL和缓存响应并不匹配,浏览器就会重要请求更新地址薄。   

即使你的Ajxa响应是动态生成的,哪怕它只适用于一个用户,那么它也应该被缓存起来。这样做可以使你的Web2.0应用程序更加快捷。

5、推迟加载内容

你可以仔细看一下你的网页,问问自己“哪些内容是页面呈现时所必需首先加载的?哪些内容和结构可以稍后再加载?

把整个过程按照onload事件分隔成两部分,JavaScript是一个理想的选择。例如,如果你有用于实现拖放和动画的JavaScript,那么它就以等待稍后加载,因为页面上的拖放元素是在初始化呈现之后才发生的。其它的例如隐藏部分的内容(用户操作之后才显现的内容)和处于折叠部分的图像也可以推迟加载

工具可以节省你的工作量:YUI Image Loader可以帮你推迟加载折叠部分的图片,YUI Get utility是包含JS和 CSS的便捷方法。比如你可以打开Firebug的Net选项卡看一下Yahoo的首页。

当性能目标和其它网站开发实践一致时就会相得益彰。这种情况下,通过程序提高网站性能的方法告诉我们,在支持JavaScript的情况下,可以先去除用户体验,不过这要保证你的网站在没有JavaScript也可以正常运行。在确定页面运行正常后,再加载脚本来实现如拖放和动画等更加花哨的效果。

6、预加载

预加载和后加载看起来似乎恰恰相反,但实际上预加载是为了实现另外一种目标。预加载是在浏览器空闲时请求将来可能会用到的页面内容(如图像、样式表和脚本)。使用这种方法,当用户要访问下一个页面时,页面中的内容大部分已经加载到缓存中了,因此可以大大改善访问速度。

下面提供了几种预加载方法:

无条件加载:触发onload事件时,直接加载额外的页面内容。以Google.com为例,你可以看一下它的spirit image图像是怎样在onload中加载的。这个spirit image图像在google.com主页中是不需要的,但是却可以在搜索结果页面中用到它。

有条件加载:根据用户的操作来有根据地判断用户下面可能去往的页面并相应的预加载页面内容。在search.yahoo.com中你可以看到如何在你输入内容时加载额外的页面内容。

有预期的加载:载入重新设计过的页面时使用预加载。这种情况经常出现在页面经过重新设计后用户抱怨“新的页面看起来很酷,但是却比以前慢”。问题可能出在用户对于你的旧站点建立了完整的缓存,而对于新站点却没有任何缓存内容。因此你可以在访问新站之前就加载一部内容来避免这种结果的出现。在你的旧站中利用浏览器的空余时间加载新站中用到的图像的和脚本来提高访问速度。

7、减少DOM元素数量

一个复杂的页面意味着需要下载更多数据,同时也意味着JavaScript遍历DOM的效率越慢。比如当你增加一个事件句柄时在500和5000个DOM元素中循环效果肯定是不一样的。

大量的DOM元素的存在意味着页面中有可以不用移除内容只需要替换元素标签就可以精简的部分。你在页面布局中使用表格了吗?你有没有仅仅为了布局而引入更多的

元素呢?也许会存在一个适合或者在语意是更贴切的标签可以供你使用。

YUI CSS utilities可以给你的布局带来巨大帮助:grids.css可以帮你实现整体布局,font.css和reset.css可以帮助你移除浏览器默认格式。它提供了一个重新审视你页面中标签的机会,比如只有在语意上有意义时才使用

,而不是因为它具有换行效果才使用它。

DOM元素数量很容易计算出来,只需要在Firebug的控制台内输入:

document.getElementsByTagName(‘*‘).length

那么多少个DOM元素算是多呢?这可以对照有很好标记使用的类似页面。比如Yahoo!主页是一个内容非常多的页面,但是它只使用了700个元素(HTML标签)。

8、根据域名划分页面内容

把页面内容划分成若干部分可以使你最大限度地实现平行下载。由于DNS查找带来的影响你首先要确保你使用的域名数量在2个到4个之间。例如,你可以把用到的HTML内容和动态内容放在www.example.org上,而把页面各种组件(图片、脚本、CSS)分别存放在statics1.example.org和statics.example.org上。

你可在Tenni Theurer和Patty Chi合写的文章Maximizing Parallel Downloads in the Carpool Lane找到更多相关信息。

9、使iframe的数量最小

ifrmae元素可以在父文档中插入一个新的HTML文档。了解iframe的工作理然后才能更加有效地使用它,这一点很重要。

Kong API Gateway 落地 Kubernetes

发表于 2021-03-09

网关顺利搭起来了,接下来就要验收的时候了。我针对 Kong Gateway + Kubernetes 的形式进行了一些基本操作的验收,主要包括创建,重启,升级等。

后端服务背景

后端服务的结构是 nginx + fpm,整体打包成一个镜像,以 deployment 的形式在 K8s 集群中部署。链路架构为

61bX9I.md.jpg

关于为什么在 Kong 网关前嵌套一个阿里云默认 SLB 的原因在之前的文章中有提到,这个不影响本次的验收。

稳定性验收

我在新建部署,重启实例和滚动升级过程中,都会发起一定并发的请求。在多实例互为备份的情况下,我期望做这些操作对服务稳定性是透明的。实际验收中,遇到了以下几个问题:

实例刚新建后遇到 5xx 报错

我们将 nginx 和 fpm 集中在一个容器里,这里就涉及到谁先运行的问题,两者之间总归有一点间隙。之前的问题就是 nginx 先启动后开始接收请求,但是对应的 fpm 还没准备好,因此产生了报错。

改进: 先运行 fpm 后运行 nginx,修改 Dockerfile 文件,

1
objectivec复制代码CMD service php5.6-fpm start && nginx -g "daemon off;"

升级过程中,Kong Gateway 显示实例 DNS error.

现象是升级过程中,出现后端服务不可用。我们选择的升级模式是滚动升级,是新建一个新版的实例后,再缩一个老版本的实例,如此滚动,直到完成升级。升级过程中,理论上不应该出现此类异常。

原因分析:Kong 的 Controller 需要将 Pods 起停信息同步到 Kong 中去,两边信息可能存在不一致。后端实例已经发生更新,但 Kong 中还保留着老实例对应的 target 信息。此时,如果请求转发到该 target,此时其后端实例有可能正在 Terminating,也有可能已经被清理了,那么该请求就会出现异常。

61bL4A.md.jpg

我的思路是通过延长后端实例实际 graceful shutdow 时间,当出现上述情况时,我们还保留着老版本实例,虽然它正处于 Terminating 状态,但仍具备处理请求的能力。但是我不能直接修改程序的 graceful shutdown 的逻辑,我通过 K8s Pod 生命周期中支持的 preStop 功能实现。

1
2
3
4
5
6
7
bash复制代码          lifecycle:
preStop:
exec:
command:
- /bin/sh
- '-c'
- sleep 10 && nginx -s quit

引申

当我们升级 Kong deployment 时会不会同样的问题,这个其实取决于 Kong 上层的负载均衡能否及时感知到 Kong 实例的起停变化,但理论上都存在同样的问题。

且 Kong deployment 默认也开启了 preStop 功能:

1
2
3
4
5
6
7
8
9
yaml复制代码    image: kong:2.2
imagePullPolicy: IfNotPresent
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- kong quit

我们适当增加一定的 “续命时间”:10s,即 sleep 10 & kong quit。

总结

将 Kong 网关和 Kubernetes 结合时,特别是升级过程中,请求的分配存在一定的缺陷,如果我们不能直接从 Kong 中入手的话,我们可以结合 K8s 的特性去尝试。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Kong API Gateway 落地 Kubernetes

发表于 2021-03-09

经过上一篇,加上自己尝试的话,基本上会对 Kong 插件的使用方式有个印象了。接下来,我们来说说自定义插件。

当现成的插件满足不了我们的需求,我们就要实现自己的插件,然后借这个机会,把整个自定义插件的开发,发布流程记录下。

资料

首先是基础的 Plugin Development Guide 和 Plugin Development Kit,这两篇只是给了我们插件内部实现的文档。接着强烈推荐 Setting up custom plugin in Kubernetes environment,这篇特别适合不熟悉 lua 的开发者,其中也给了一个非常好的 demo 。

新插件代码

插件需求:我尝试使用 Kong Proxy Cache(请求缓存插件)后发现产生了一个疑问:缓存的命中率如何?

从监控上,我能看到所有请求的统计情况,但是我看不出有多少请求是命中了缓存的,可能是那些时延比较低的请求,但是不够精确和量化。我就想如果能在监控信息中夹带是否命中的数据,那么在监控展示时,根据这个数据过滤,然后就能得到准确的命中率了。

插件实现思路:观察 Kong grafana 监控,我们注意到请求的监控数据条目是 kong_http_status。直接的想法是,要是在这个 Prometheus 条目上增加名为 “cache” 的 tag 就好了。

然后我们顺着这个想法找到 Prometheus Plugin 源码,修改源码,详见:github.com/yuyulei/blo…

我们主要改动的地方有:

  • 修改 plugin 的命名,一定要跟原来不一样。
1
2
3
4
ini复制代码// in handler.lua file

-- local prometheus = require "kong.plugins.prometheus.exporter"
local prometheus = require "kong.plugins.caoliao-prometheus.exporter"
  • 增加自定义 tag: cache。
1
2
3
4
javascript复制代码  metrics.status = prometheus:counter("http_status",
"HTTP status codes per service/route in Kong",
-- {"service", "route", "code"})
{"service", "route", "code", "cache"})
1
2
3
4
css复制代码    labels_table[4] = "Unknown"
if message.response.headers["x-cache-status"] then
labels_table[4] = message.response.headers["x-cache-status"]
end

我们在这里需要注意的是:插件的执行顺序。根据我们的需求,监控插件需要判断出哪些请求命中了缓存,哪些没有命中,所以必须安排在缓存插件之后执行。

简单解释下 HTTP header: x-cache-status。该 header 是经过缓存插件后附加到 response Header 上。其中取值有:Hit(命中),Miss(未命中),Bypass(不考虑缓存),Refresh(缓存超时)。我们不在乎具体取值是什么,如果发现 header 中有 “x-cache-status” ,就在该请求的监控条目中设置 cache tag。

集成新插件

我们接下来需要将新插件集成到 Kong 网关中。我觉得,在 Kong Ingress Controller(K8s 集群)环境下集成插件比原生 Kong 环境更方便。

先将代码以 configMap 的形式录入到 K8s 集群,

1
ini复制代码kubectl create cm kong-plugin-caoliao-prometheus --from-file=caoliao-prometheus -n kong

然后修改 KIC deployment,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
yaml复制代码      - env:
- name: KONG_PLUGINS
value: bundled,caoliao-prometheus
- name: KONG_LUA_PACKAGE_PATH
value: /opt/?.lua;;

volumeMounts:
- mountPath: /opt/kong/plugins/caoliao-prometheus
name: kong-plugin-caoliao-prometheus

volumes:
- configMap:
name: kong-plugin-caoliao-prometheus
name: kong-plugin-caoliao-prometheus

如上如升级网关即可。

监控展示

设计 3 条 Metrics Query,分别是命中缓存的请求 RPS,总请求 RPS 以及命中率。

61bqNd.md.jpg

总结

我们通过小小修改了 Kong Prometheus Plugin 源码,走完了自定义插件从开发到集成发布的全过程。Kong 的很多插件,我们都是可以找到源码的,如果是和我一样不熟悉 lua 的开发者,我建议优先考虑在源码的基础上改进,而不要自己造轮子。

另外,Kong 还支持 golang 插件,有机会我也想尝试下。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

小白也能看懂的锁升级过程和锁状态

发表于 2021-03-09

一、前言

锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁,这四种锁状态分别代表什么,为什么会有锁升级?其实在 JDK 1.6之前,synchronized 还是一个重量级锁,是一个效率比较低下的锁,但是在JDK 1.6后,Jvm为了提高锁的获取与释放效率对(synchronized )进行了优化,引入了 偏向锁 和 轻量级锁 ,从此以后锁的状态就有了四种(无锁、偏向锁、轻量级锁、重量级锁),并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别),不能锁降级(高级别到低级别) ,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

二、锁的四种状态

在 synchronized 最初的实现方式是 “阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长” ,这种方式就是 synchronized实现同步最初的方式,这也是当初开发者诟病的地方,这也是在JDK6以前 synchronized效率低下的原因,JDK6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

所以目前锁状态一种有四种,从级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁,锁状态只能升级,不能降级

如图所示:

在这里插入图片描述

三、锁状态的思路以及特点

锁状态 存储内容 标志位
无锁 对象的hashCode、对象分代年龄、是否是偏向锁(0) 01
偏向锁 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量的指针 11

四、锁对比

锁 优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到索竞争的线程,使用自旋会消耗CPU 追求响应速度,同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步块执行速度较慢

五、Synchronized锁

synchronized 用的锁是存在Java对象头里的,那么什么是对象头呢?

5.1 Java 对象头

我们以 Hotspot 虚拟机为例,Hopspot 对象头主要包括两部分数据:Mark Word(标记字段) 和 Klass Pointer(类型指针)

Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

在上面中我们知道了,synchronized 用的锁是存在Java对象头里的,那么具体是存在对象头哪里呢?答案是:存在锁对象的对象头的Mark Word中,那么MarkWord在对象头中到底长什么样,它到底存储了什么呢?

在64位的虚拟机中:

在这里插入图片描述

在32位的虚拟机中:

在这里插入图片描述

下面我们以 32位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的

无锁:对象头开辟 25bit 的空间用来存储对象的 hashcode ,4bit 用于存放对象分代年龄,1bit 用来存放是否偏向锁的标识位,2bit 用来存放锁标识位为01

偏向锁: 在偏向锁中划分更细,还是开辟 25bit 的空间,其中23bit 用来存放线程ID,2bit 用来存放 Epoch,4bit 存放对象分代年龄,1bit 存放是否偏向锁标识, 0表示无锁,1表示偏向锁,锁的标识位还是01

轻量级锁:在轻量级锁中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00

重量级锁: 在重量级锁中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为11

GC标记: 开辟30bit 的内存空间却没有占用,2bit 空间存放锁标志位为11。

其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态

关于内存的分配,我们可以在git中openJDK中 markOop.hpp 可以看出:

1
2
3
4
5
6
7
8
9
10
ini复制代码public:
// Constants
enum { age_bits = 4,
lock_bits = 2,
biased_lock_bits = 1,
max_hash_bits = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
hash_bits = max_hash_bits > 31 ? 31 : max_hash_bits,
cms_bits = LP64_ONLY(1) NOT_LP64(0),
epoch_bits = 2
};
  • age_bits: 就是我们说的分代回收的标识,占用4字节
  • lock_bits: 是锁的标志位,占用2个字节
  • biased_lock_bits: 是是否偏向锁的标识,占用1个字节
  • max_hash_bits: 是针对无锁计算的hashcode 占用字节数量,如果是32位虚拟机,就是 32 - 4 - 2 -1 = 25 byte,如果是64 位虚拟机,64 - 4 - 2 - 1 = 57 byte,但是会有 25 字节未使用,所以64位的 hashcode 占用 31 byte
  • hash_bits: 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取31,否则取真实的字节数
  • cms_bits: 不是64位虚拟机就占用 0 byte,是64位就占用 1byte
  • epoch_bits: 就是 epoch 所占用的字节大小,2字节。

5.2 Monitor

Monitor 可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个 Java 对象就有一把看不见的锁,称为内部锁或者 Monitor 锁。

Monitor 是线程私有的数据结构,每一个线程都有一个可用 monitor record 列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联,同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为重量级锁。

随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking=false来禁用偏向锁。

六、锁的分类

6.2 无锁

无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。

6.3 偏向锁

初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。

当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。

关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。

6.4 轻量级锁(自旋锁)

在这里插入图片描述

轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋(关于自旋的介绍见文末)的形式尝试获取锁,线程不会阻塞,从而提高性能。

轻量级锁的获取主要由两种情况:

① 当关闭偏向锁功能时;

② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。

一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

6.4 重量级锁

重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。

简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资

五、总结

文中讲述了锁的四种状态以及锁是如何一步一步升级的过程,文中有理解不到位或者有问题的地方,欢迎大家在评论区中下方指出和交流,谢谢大家

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Kong API Gateway 落地 Kubernetes

发表于 2021-03-09

开启 Kong 网关在 Kubernetes 集群的实践。

背景

其实在开始将 Kong 之前,我们不能不先回答两个问题是:1. 为什么要有 API Gateway ?2. 为什么选 Kong 作为网关 ?

第一个问题直接略过。简单回答下第二个问题。

面对这问题的时候,其实是一个技术选型的问题,因此我们作为普通开发人员,一般从以下几个方面去平衡,去选择:

  1. 技术栈:除了网关本身的实现,也要考虑与自身业务的技术对接。
  2. 费用:开源或收费。
  3. 成熟:是否经受生产环境验证,是否被光大用户验证。
  4. 部署/运维成本:与云原生(Kubernetes)的兼容性。
  5. 功能/插件/中间件:网关功能是否丰富,是否有丰富的插件库,是否方便添加自定义插件/中间件。
  6. 接受水平:开发人员的接受能力,能力越强,那么对 API Gateway 要求就可以放低,反之亦然。

其中 JAVA 系不需要考虑上面,考虑就是 Spring 全家桶。然后,我后来也问过阿里云的技术支持,他们告诉我,他们的企业用户主要以Kong和tyk为主。

然后我说下选择的原因。我所处的环境是 nginx + php 的技术栈,对 nginx 和 lua 有倾向性,然后周围的同事觉得平时接触过,就觉得能驾驭 Kong,因此相中了 Kong。

布局

API Gateway 的加入或者调整往往伴随着业务技术架构的变化,而我也面临 SLB(负载均衡) + AliyunECS(nginx+php) 转型到 SLB + Kubernetes 的变迁。

61bh1x.jpg

61b4c6.jpg

另外我们还要考虑如何从 ECS 架构平滑地迁移到 Kubernetes 架构,以及业务 APP 和网关 Kong API Gateway 如何落地的问题。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

助力面试之ConcurrentHashMap面试灵魂拷问,你

发表于 2021-03-08

前言

本文从 ConcurrentHashMap 常见的面试问题引入话题,并逐步揭开其设计原理,相信读完本文,对面试中的相关问题会有很大的帮助。

HashMap 在我们日常的开发中使用频率最高的一个工具类之一,然而使用 HashMap 最大的问题之一就是它是线程不安全的,如果我们想要线程安全应该怎么办呢?这时候就可以选择使用 ConcurrentHashMap,ConcurrentHashMap 和 HashMap 的功能是基本一样的,ConcurrentHashMap 是 HashMap 的线程安全版本。

因 ConcurrentHashMap 和 HashMap 在 jdk1.8 版本中排除线程的安全性方面,其他的设计都很类似,所以有很多相同的设计思想本文不会做太多重复介绍,如果大家不了解 HashMap 底层实现原理,建议在阅读本文可以先阅读 金三银四助力面试-手把手轻松读懂HashMap源码 了解 HashMap 的设计思想。

ConcurrentHashMap 原理

ConcurrentHashMap 是 HashMap 的线程安全版本,其内部和 HashMap 一样,也是采用了数组 + 链表 + 红黑树的方式来实现。

如何实现线程的安全性?加锁。但是这个锁应该怎么加呢?在 HashTable 中,是直接在 put 和 get 方法上加上了 synchronized,理论上来说 ConcurrentHashMap 也可以这么做,但是这么做锁的粒度太大,会非常影响并发性能,所以在 ConcurrentHashMap 中并没有采用这么直接简单粗暴的方法,其内部采用了非常精妙的设计,大大减少了锁的竞争,提升了并发性能。

ConcurrentHashMap 中的初始化和 HashMap 中一样,而且容量也会调整为 2 的 N 次幂,在这里不做重复介绍这么做的原因。

JDK1.8 版本 ConcurrentHashMap 做了什么改进

在 JDK1.7 版本中,ConcurrentHashMap 由数组 + Segment + 分段锁实现,其内部分为一个个段(Segment)数组,Segment 通过继承 ReentrantLock 来进行加锁,通过每次锁住一个 segment 来降低锁的粒度而且保证了每个 segment 内的操作的线程安全性,从而实现全局线程安全。下图就是 JDK1.7 版本中 ConcurrentHashMap 的结构示意图:

但是这么做的缺陷就是每次通过 hash 确认位置时需要 2 次才能定位到当前 key 应该落在哪个槽:

  1. 通过 hash 值和 段数组长度-1 进行位运算确认当前 key 属于哪个段,即确认其在 segments 数组的位置。
  2. 再次通过 hash 值和 table 数组(即 ConcurrentHashMap 底层存储数据的数组)长度 - 1进行位运算确认其所在桶。

为了进一步优化性能,在 jdk1.8 版本中,对 ConcurrentHashMap 做了优化,取消了分段锁的设计,取而代之的是通过 cas 操作和 synchronized 关键字来实现优化,而扩容的时候也利用了一种分而治之的思想来提升扩容效率,在 JDK1.8 中 ConcurrentHashMap 的存储结构和 HashMap 基本一致,如下图所示:

为什么 key 和 value 不允许为 null

在 HashMap 中,key 和 value 都是可以为 null 的,但是在 ConcurrentHashMap 中却不允许,这是为什么呢?

作者 Doug Lea 本身对这个问题有过回答,在并发编程中,null 值容易引来歧义, 假如先调用 get(key) 返回的结果是 null,那么我们无法确认是因为当时这个 key 对应的 value 本身放的就是 null,还是说这个 key 值根本不存在,这会引起歧义,如果在非并发编程中,可以进一步通过调用 containsKey 方法来进行判断,但是并发编程中无法保证两个方法之间没有其他线程来修改 key 值,所以就直接禁止了 null 值的存在。

而且作者 Doug Lea 本身也认为,假如允许在集合,如 map 和 set 等存在 null 值的话,即使在非并发集合中也有一种公开允许程序中存在错误的意思,这也是 Doug Lea 和 Josh Bloch(HashMap作者之一) 在设计问题上少数不同意见之一,而 ConcurrentHashMap 是 Doug Lea 一个人开发的,所以就直接禁止了 null 值的存在。

ConcurrentHashMap 如何保证线程的安全性

在 ConcurrentHashMap 中,采用了大量的分而治之的思想来降低锁的粒度,提升并发性能。其源码中大量使用了 cas 操作来保证安全性,而不是和 HashTable 一样,不论什么方法,直接简单粗暴的使用 synchronized关键字来实现,接下来的原理分析中,部分和 HashMap 类似之处本文就不在重复,本文主要从安全性方面来分析 ConcurrentHashMap 的设计。

如何用 CAS 保证数组初始化的安全

下面就是初始化的方法:

这里面有一个非常重要的变量 sizeCtl,这个变量对理解整个 ConcurrentHashMap 的原理非常重要。

sizeCtl 有四个含义:

  • sizeCtl<-1 表示有 N-1 个线程正在执行扩容操作,如 -2 就表示有 2-1 个线程正在扩容。
  • sizeCtl=-1 占位符,表示当前正在初始化数组。
  • sizeCtl=0 默认状态,表示数组还没有被初始化。
  • sizeCtl>0 记录下一次需要扩容的大小。

知道了这个变量的含义,上面的方法就好理解了,第二个分支采用了 CAS 操作,因为 SIZECTL 默认为 0,所以这里如果可以替换成功,则当前线程可以执行初始化操作,CAS 失败,说明其他线程抢先一步把 sizeCtl 改为了 -1。扩容成功之后会把下一次扩容的阈值赋值给 sc,即 sizeClt。

put 操作如何保证数组元素的可见性

ConcurrentHashMap 中存储数据采用的 Node 数组是采用了 volatile 来修饰的,但是这只能保证数组的引用在不同线程之间是可用的,并不能保证数组内部的元素在各个线程之间也是可见的,所以这里我们判定某一个下标是否有元素,并不能直接通过下标来访问,那么应该如何访问呢?源码给你答案:

可以看到,这里是通过 tabAt 方法来获取元素,而 tableAt 方法实际上就是一个 CAS 操作:

如果发现当前节点元素为空,也是通过 CAS 操作(casTabAt)来存储当前元素。

如果当前节点元素不为空,则会使用 synchronized 关键字锁住当前节点,并进行对应的设值操作:

精妙的计数方式

在 HashMap 中,调用 put 方法之后会通过 ++size 的方式来存储当前集合中元素的个数,但是在并发模式下,这种操作是不安全的,所以不能通过这种方式,那么是否可以通过 CAS 操作来修改 size 呢?

直接通过 CAS 操作来修改 size 是可行的,但是假如同时有非常多的线程要修改 size 操作,那么只会有一个线程能够替换成功,其他线程只能不断的尝试 CAS,这会影响到 ConcurrentHashMap 集合的性能,所以作者就想到了一个分而治之的思想来完成计数。

作者定义了一个数组来计数,而且这个用来计数的数组也能扩容,每次线程需要计数的时候,都通过随机的方式获取一个数组下标的位置进行操作,这样就可以尽可能的降低了锁的粒度,最后获取 size 时,则通过遍历数组来实现计数:

1
2
3
4
5
6
7
java复制代码//用来计数的数组,大小为2的N次幂,默认为2
private transient volatile CounterCell[] counterCells;

@sun.misc.Contended static final class CounterCell {//数组中的对象
volatile long value;//存储元素个数
CounterCell(long x) { value = x; }
}

addCount 计数方法

接下来我们看看 addCount 方法:

首先会判断 CounterCell 数组是不是为空,需要这里的是,这里的 CAS 操作是将 BASECOUNT 和 baseCount 进行比较,如果相等,则说明当前没有其他线程过来修改 baseCount(即 CAS 操作成功),此时则不需要使用 CounterCell 数组,而直接采用 baseCount 来计数。

假如 CounterCell 为空且 CAS 失败,那么就会通过调用 fullAddCount 方法来对 CounterCell 数组进行初始化。

fullAddCount 方法

这个方法也很长,看起来比较复杂,里面包含了对 CounterCell 数组的初始化和赋值等操作。

初始化 CounterCell 数组

我们先不管,直接进入初始化的逻辑:

这里面有一个比较重要的变量 cellsBusy,默认是 0,表示当前没有线程在初始化或者扩容,所以这里判断如果 cellsBusy==0,而 as 其实在前面就是把全局变量 CounterCell 数组的赋值,这里之所以再判断一次就是再确认有没有其他线程修改过全局数组 CounterCell,所以条件满足的话就会通过 CAS 操作修改 cellsBusy 为 1,表示当前自己在初始化了,其他线程就不能同时进来初始化操作了。

最后可以看到,默认是一个长度为 2 的数组,也就是采用了 2 个数组位置进行存储当前 ConcurrentHashMap 的元素数量。

CounterCell 如何赋值

初始化完成之后,如果再次调用 put 方法,那么就会进入 fullAddCount 方法的另一个分支:

这里面首先判断了 CounterCell 数组不为空,然后会再次判断数组中的元素是不是为空,因为如果元素为空,就需要初始化一个 CounterCell 对象放到数组,而如果元素不为空,则只需要 CAS 操作替换元素中的数量即可。

所以这里面的逻辑也很清晰,初始化 CounterCell 对象的时候也需要将 cellBusy 由 0 改成 1。

计数数组 CounterCell 也能扩容吗

最后我们再继续看其他分支:

主要看上图红框中的分支,一旦会进入这个分支,就说明前面所有分支都不满足,即:

  • 当前 CounterCell 数组已经初始化完成。
  • 当前通过 hash 计算出来的 CounterCell 数组下标中的元素不为 null。
  • 直接通过 CAS 操作修改 CounterCell 数组中指定下标位置中对象的数量失败,说明有其他线程在竞争修改同一个数组下标中的元素。
  • 当前操作不满足不允许扩容的条件。
  • 当前没有其他线程创建了新的 CounterCell 数组,且当前 CounterCell 数组的大小仍然小于 CPU 数量。

所以接下来就需要对 CounterCell 数组也进行扩容,这个扩容的方式和 ConcurrentHashMap 的扩容一样,也是将原有容量乘以 2,所以其实 CounterCell 数组的容量也是满足 2 的 N 次幂。

ConcurrentHashMap 的扩容

接下来我们需要回到 addCount 方法,因为这个方法在添加元素数量的同时,也会判断当前 ConcurrentHashMap 的大小是否达到了扩容的阈值,如果达到,需要扩容。

扩容也能支持并发吗

这里可能令大家有点意外的是,ConcurrentHashMap 扩容也支持多线程同时进行,这又是如何做到的呢?接下来就让我们回到 addCount 方法一探究竟。

这里 check 是传进来的链表长度,>=0 才开始检查是否需要扩容,紧挨之后是一个 while 循环,主要是满足两个条件:

  • 前面我们提到,sizeCtl在初始化的时候会被赋值为下一次扩容的大小(扩容之后也会),所以 >=sizeCtl 表示的就是是否达到扩容阈值。
  • table 不为 null 且当前数组长度小于最大值 2 的 30 次方。
扩容戳有什么用

当满足扩容条件之后,首先会先调用一个方法来获取扩容戳,这个扩容戳比较有意思,要理解扩容戳,必须从二进制的角度来分析。resizeStamp 方法就一句话,其中 RESIZE_STAMP_BITS 是一个默认值 16。

1
2
3
java复制代码 static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

这里面关键就是 Integer.numberOfLeadingZeros(n) 这个方法,这个方法源码就不贴出来了,实际上这个方法就是做一件事,那就是获取当前数据转成二进制后的最高非 0 位前的 0 的个数。

这句话有点拗口,我们举个例子,就以 16 为准,16 转成二进制是 10000,最高非 0 位是在第 5 位,因为 int 类型是 32 位,所以他前面还有 27 位,而且都是 0,那么这个方法得到的结果就是 27(1 的前面还有 27 个 0)。

然后 1 << (RESIZE_STAMP_BITS - 1) 在当前版本就是 1<<15,也就是得到一个二进制数 1000000000000000,这里也是要做一件事,把这个 1 移动到第 16 位。最后这两个数通过 | 操作一定得到的结果就是第 16 位是 1,因为 int 是 32 位,最多也就是 32 个 0,而且因为 n 的默认大小是 16(ConcurrentHashMap 默认大小),所以实际上最多也就是 27(11011),也就是说这个数最高位的 1 也只是在第五位,执行 | 运算最多也就是影响低 5 位的结果。

27 转成二进制为 0000000000000000000000000011011,然后和 00000000000000001000000000000000 执行 | 运算,最终得到的而结果就是 00000000000000010000000000011011,

注意:这里之所以要保证第 16 位为 1,是为了保证 sizeCtl 变量为负数,因为前面我们提到,这个变量为负数才代表当前有线程在扩容,至于这个变量和 sizeCtl 的关系后面会介绍。

首次扩容为什么计数要 +2 而不是 +1

首次扩容一定不会走前面两个条件,而是走的最后一个红框内条件,这个条件通过 CAS 操作将 rs 左移了 16(RESIZE_STAMP_SHIFT)位,然后加上一个 2,这个代表什么意思呢?为什么是加 2 呢?

要回答这个问题我们先回答另一个问题,上面通过方法获得的扩容戳 rs 究竟有什么用?实际上这个扩容戳代表了两个含义:

  • 高 16 位代表当前扩容的标记,可以理解为一个纪元。
  • 低 16 位代表了扩容的线程数。

知道了这两个条件就好理解了,因为 rs 最终是要赋值给 sizeCtl 的,而 sizeCtl 负数才代表扩容,而将 rs 左移 16 位就刚好使得最高位为 1,此时低 16 位全部是 0,而因为低 16 位要记录扩容线程数,所以应该 +1,但是这里是 +2,原因是 sizeCtl 中 -1 这个数值已经被使用了,用来代替当前有线程准备扩容,所以如果直接 +1 是会和标志位发生冲突。

所以继续回到上图中的第二个红框,就是正常继续 +1 了,只有初始化第一次记录扩容线程数的时候才需要 +2。

扩容条件

接下来我们继续看上图中第一个红框,这里面有 5 个条件,代表是满足这 5 个条件中的任意一个,则不进行扩容:

  1. (sc >>> RESIZE_STAMP_SHIFT) != rs 这个条件实际上有 bug,在 JDK12 中已经换掉。
  2. sc == rs + 1 表示最后一个扩容线程正在执行首位工作,也代表扩容即将结束。
  3. sc == rs + MAX_RESIZERS 表示当前已经达到最大扩容线程数,所以不能继续让线程加入扩容。
  4. 扩容完成之后会把 nextTable(扩容的新数组) 设为 null。
  5. transferIndex <= 0 表示当前可供扩容的下标已经全部分配完毕,也代表了当前线程扩容结束。

多并发下如何实现扩容

在多并发下如何实现扩容才不会冲突呢?可能大家都想到了采用分而治之的思想,在 ConcurrentHashMap 中采用的是分段扩容法,即每个线程负责一段,默认最小是 16,也就是说如果 ConcurrentHashMap 中只有 16 个槽位,那么就只会有一个线程参与扩容。如果大于 16 则根据当前 CPU 数来进行分配,最大参与扩容线程数不会超过 CPU 数。

扩容空间和 HashMap 一样,每次扩容都是将原空间大小左移一位,即扩大为之前的两倍。注意这里的 transferIndex 代表的就是推进下标,默认为旧数组的大小。

扩容时的数据迁移如何保证安全性

初始化好了新的数组,接下来就是要准备确认边界。也就是要确认当前线程负责的槽位,确认好之后会从大到小开始往前推进,比如线程一负责 1-16,那么对应的数组边界就是 0-15,然后会从最后一位 15 开始迁移数据:

这里面有三个变量比较关键:

  • fwd 节点,这个代表的是占位节点,最关键的就是这个节点的 hash 值为 -1,所以一旦发现某一个节点中的 hash 值为 -1 就可以知道当前节点已经被迁移了。
  • advance:代表是否可以继续推进下一个槽位,只有当前槽位数据被迁移完成之后才可以设置为 true
  • finishing:是否已经完成数据迁移。

知道了这几个变量,再看看上面的代码,第一次一定会进入 while 循环,因为默认 advance 为 true,第一次进入循环的目的为了确认边界,因为边界值还没有确认,所以会直接走到最后一个分支,通过 CAS 操作确认边界。

确认边界这里直接表述很难理解,我们通过一个例子来说明:

假设说最开始的空间为 16,那么扩容后的空间就是 32,此时 transferIndex 为旧数组大小 16,而在第二个 if判断中,transferIndex 赋值给了 nextIndex,所以 nextIndex 为 1,而 stride 代表的是每个线程负责的槽位数,最小就是 16,所以 stride 也是 16,所以 nextBound= nextIndex > stride ? nextIndex - stride : 0 皆可以得到:nextBound=0 和 i=15 了,也就是当前线程负责 0-15 的数组下标,且从 0 开始推进,确认边界后立刻将 advance 设置为 false,也就是会跳出 while 循环,从而执行下面的数据迁移部分逻辑。

PS:因为 nextBound=0,所以 CAS 操作实际上也是把 transferIndex 变成了 0,表示当前扩容的数组下标已经全部分配完毕,这也是前面不满足扩容的第 5 个条件。

数据迁移时,会使用 synchronized 关键字对当前节点进行加锁,也就是说锁的粒度精确到了每一个节点,可以说大大提升了效率。加锁之后的数据迁移和 HashMap 基本一致,也是通过区分高低位两种情况来完成迁移,在本文就不重复讲述。

当前节点完成数据迁移之后,advance 变量会被设置为 true,也就是说可以继续往前推进节点了,所以会重新进入上面的 while 循环的前面两个分支,把下标 i 往前推进之后再次把 advance 设置为 false,然后重复操作,直到下标推进到 0 完成数据迁移。

while 循环彻底结束之后,会进入到下面这个 if 判断,红框中就是当前线程自己完成了迁移之后,会将扩容线程数进行递减,递减之后会再次通过一个条件判断,这个条件其实就是前面进入扩容前条件的反推,如果成立说明扩容已经完成,扩容完成之后会将 nextTable 设置为 null,所以上面不满足扩容的第 4 个条件就是在这里设置的。

总结

本文主要讲述了 ConcurrentHashMap 中是如何保证安全性的,并且挑选了一些比较经典的面试常用问题进行分析解答,在整个 ConcurrentHashMap 中,整个思想就是降低锁的粒度,减少锁的竞争,所以采用了大量的分而治之的思想,比如多线程同时进行扩容,以及通过一个数组来实现 size 的计数等。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

几千次的重复提交,我用 SpringBoot+Redis 居

发表于 2021-03-08

公众号 : Java小咖秀 ,网站 :www.javaxks.com ,来源:jianshu.com/p/c806003a8530

前言

在实际的开发项目中,一个对外暴露的接口往往会面临很多次请求,我们来解释一下幂等的概念:任意多次执行所产生的影响均与一次执行的影响相同。按照这个含义,最终的含义就是 对数据库的影响只能是一次性的,不能重复处理。如何保证其幂等性,通常有以下手段:

数据库建立唯一性索引,可以保证最终插入数据库的只有一条数据
token机制,每次接口请求前先获取一个token,然后再下次请求的时候在请求的header体中加上这个token,后台进行验证,如果验证通过删除token,下次请求再次判断token
悲观锁或者乐观锁,悲观锁可以保证每次for update的时候其他sql无法update数据(在数据库引擎是innodb的时候,select的条件必须是唯一索引,防止锁全表)
先查询后判断,首先通过查询数据库是否存在数据,如果存在证明已经请求过了,直接拒绝该请求,如果没有存在,就证明是第一次进来,直接放行。
redis实现自动幂等的原理图:

搭建redis的服务Api

首先是搭建redis服务器。

引入springboot中到的redis的stater,或者Spring封装的jedis也可以,后面主要用到的api就是它的set方法和exists方法,这里我们使用springboot的封装好的redisTemplate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
java复制代码/**
* redis工具类
*/
@Component
public class RedisService {

@Autowired
private RedisTemplate redisTemplate;

/**
* 写入缓存
* @param key
* @param value
* @return
*/
public boolean set(final String key, Object value) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}


/**
* 写入缓存设置时效时间
* @param key
* @param value
* @return
*/
public boolean setEx(final String key, Object value, Long expireTime) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}


/**
* 判断缓存中是否有对应的value
* @param key
* @return
*/
public boolean exists(final String key) {
return redisTemplate.hasKey(key);
}

/**
* 读取缓存
* @param key
* @return
*/
public Object get(final String key) {
Object result = null;
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
result = operations.get(key);
return result;
}

/**
* 删除对应的value
* @param key
*/
public boolean remove(final String key) {
if (exists(key)) {
Boolean delete = redisTemplate.delete(key);
return delete;
}
return false;

}

}

自定义注解AutoIdempotent

自定义一个注解,定义此注解的主要目的是把它添加在需要实现幂等的方法上,凡是某个方法注解了它,都会实现自动幂等。后台利用反射如果扫描到这个注解,就会处理这个方法实现自动幂等,使用元注解ElementType.METHOD表示它只能放在方法上,etentionPolicy.RUNTIME表示它在运行时

1
2
3
4
5
java复制代码@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {

}

token创建和检验

token服务接口:我们新建一个接口,创建token服务,里面主要是两个方法,一个用来创建token,一个用来验证token。创建token主要产生的是一个字符串,检验token的话主要是传达request对象,为什么要传request对象呢?主要作用就是获取header里面的token,然后检验,通过抛出的Exception来获取具体的报错信息返回给前端
public interface TokenService {

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码/**
* 创建token
* @return
*/
public String createToken();

/**
* 检验token
* @param request
* @return
*/
public boolean checkToken(HttpServletRequest request) throws Exception;

}
token的服务实现类:token引用了redis服务,创建token采用随机算法工具类生成随机uuid字符串,然后放入到redis中(为了防止数据的冗余保留,这里设置过期时间为10000秒,具体可视业务而定),如果放入成功,最后返回这个token值。checkToken方法就是从header中获取token到值(如果header中拿不到,就从paramter中获取),如若不存在,直接抛出异常。这个异常信息可以被拦截器捕捉到,然后返回给前端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
java复制代码@Service
public class TokenServiceImpl implements TokenService {

@Autowired
private RedisService redisService;


/**
* 创建token
*
* @return
*/
@Override
public String createToken() {
String str = RandomUtil.randomUUID();
StrBuilder token = new StrBuilder();
try {
token.append(Constant.Redis.TOKEN_PREFIX).append(str);
redisService.setEx(token.toString(), token.toString(),10000L);
boolean notEmpty = StrUtil.isNotEmpty(token.toString());
if (notEmpty) {
return token.toString();
}
}catch (Exception ex){
ex.printStackTrace();
}
return null;
}


/**
* 检验token
*
* @param request
* @return
*/
@Override
public boolean checkToken(HttpServletRequest request) throws Exception {

String token = request.getHeader(Constant.TOKEN_NAME);
if (StrUtil.isBlank(token)) {// header中不存在token
token = request.getParameter(Constant.TOKEN_NAME);
if (StrUtil.isBlank(token)) {// parameter中也不存在token
throw new ServiceException(Constant.ResponseCode.ILLEGAL_ARGUMENT, 100);
}
}

if (!redisService.exists(token)) {
throw new ServiceException(Constant.ResponseCode.REPETITIVE_OPERATION, 200);
}

boolean remove = redisService.remove(token);
if (!remove) {
throw new ServiceException(Constant.ResponseCode.REPETITIVE_OPERATION, 200);
}
return true;
}
}

拦截器的配置

web配置类,实现WebMvcConfigurerAdapter,主要作用就是添加autoIdempotentInterceptor到配置类中,这样我们到拦截器才能生效,注意使用@Configuration注解,这样在容器启动是时候就可以添加进入context中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
java复制代码@Configuration
public class WebConfiguration extends WebMvcConfigurerAdapter {

@Resource
private AutoIdempotentInterceptor autoIdempotentInterceptor;

/**
* 添加拦截器
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(autoIdempotentInterceptor);
super.addInterceptors(registry);
}
}
拦截处理器:主要的功能是拦截扫描到AutoIdempotent到注解到方法,然后调用tokenService的checkToken()方法校验token是否正确,如果捕捉到异常就将异常信息渲染成json返回给前端
/**
* 拦截器
*/
@Component
public class AutoIdempotentInterceptor implements HandlerInterceptor {

@Autowired
private TokenService tokenService;

/**
* 预处理
*
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
//被ApiIdempotment标记的扫描
AutoIdempotent methodAnnotation = method.getAnnotation(AutoIdempotent.class);
if (methodAnnotation != null) {
try {
return tokenService.checkToken(request);// 幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示
}catch (Exception ex){
ResultVo failedResult = ResultVo.getFailedResult(101, ex.getMessage());
writeReturnJson(response, JSONUtil.toJsonStr(failedResult));
throw ex;
}
}
//必须返回true,否则会被拦截一切请求
return true;
}


@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

}

/**
* 返回的json值
* @param response
* @param json
* @throws Exception
*/
private void writeReturnJson(HttpServletResponse response, String json) throws Exception{
PrintWriter writer = null;
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html; charset=utf-8");
try {
writer = response.getWriter();
writer.print(json);

} catch (IOException e) {
} finally {
if (writer != null)
writer.close();
}
}

}

测试用例

模拟业务请求类,首先我们需要通过/get/token路径通过getToken()方法去获取具体的token,然后我们调用testIdempotence方法,这个方法上面注解了@AutoIdempotent,拦截器会拦截所有的请求,当判断到处理的方法上面有该注解的时候,就会调用TokenService中的checkToken()方法,如果捕获到异常会将异常抛出调用者,下面我们来模拟请求一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
java复制代码@RestController
public class BusinessController {


@Resource
private TokenService tokenService;

@Resource
private TestService testService;


@PostMapping("/get/token")
public String getToken(){
String token = tokenService.createToken();
if (StrUtil.isNotEmpty(token)) {
ResultVo resultVo = new ResultVo();
resultVo.setCode(Constant.code_success);
resultVo.setMessage(Constant.SUCCESS);
resultVo.setData(token);
return JSONUtil.toJsonStr(resultVo);
}
return StrUtil.EMPTY;
}


@AutoIdempotent
@PostMapping("/test/Idempotence")
public String testIdempotence() {
String businessResult = testService.testIdempotence();
if (StrUtil.isNotEmpty(businessResult)) {
ResultVo successResult = ResultVo.getSuccessResult(businessResult);
return JSONUtil.toJsonStr(successResult);
}
return StrUtil.EMPTY;
}
}

使用postman请求,首先访问get/token路径获取到具体到token:

利用获取到到token,然后放到具体请求到header中,可以看到第一次请求成功,接着我们请求第二次:

第二次请求,返回到是重复性操作,可见重复性验证通过,再多次请求到时候我们只让其第一次成功,第二次就是失败:

总结

本篇博客介绍了使用springboot和拦截器、redis来优雅的实现接口幂等,对于幂等在实际的开发过程中是十分重要的,因为一个接口可能会被无数的客户端调用,如何保证其不影响后台的业务处理,如何保证其只影响数据一次是非常重要的,它可以防止产生脏数据或者乱数据,也可以减少并发量,实乃十分有益的一件事。而传统的做法是每次判断数据,这种做法不够智能化和自动化,比较麻烦。而今天的这种自动化处理也可以提升程序的伸缩性。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

sa-token之前后台分离模式下如何完成权限认证

发表于 2021-03-08

前言

====

何为无Cookie模式?

无Cookie:特指不支持Cookie功能的终端,通俗来讲就是我们常说的 —— 前后台分离模式

常规PC端鉴权方法,一般由Cookie模式完成,而Cookie有两个特性:
1.可由后端控制写入
2.每次请求自动提交

这就使得我们在前端代码中,无需任何特殊操作,就能完成鉴权的全部流程(因为整个流程都是后端控制完成的)
而在app、小程序等前后台分离场景中,一般是没有Cookie这一功能的,此时大多数人都会一脸懵逼,咋进行鉴权啊?

见招拆招,其实答案很简单:

  • 不能后端控制写入了,就前端自己写入(难点在后端如何将token传递到前端)
  • 每次请求不能自动提交了,那就手动提交(难点在前端如何将token传递到后端,同时后端将其读取出来)

使用方式

1、后端将 token 返回到前端

1.首先调用 StpUtil.setLoginId(Object loginId) 进行登录
2.调用 StpUtil.getTokenInfo() 返回当前会话的token详细参数

  • 此方法返回一个对象,其有两个关键属性:tokenName和tokenValue(token的名称和token的值)
  • 将此对象传递到前台,让前端人员将这两个值保存到本地

2、前端将 token 提交到后端

1.无论是app还是小程序,其传递方式都大同小异
2.那就是,将token塞到请求header里 ,格式为:{tokenName: tokenValue}
3.以经典跨端框架 uni-app 为例:
方式1,简单粗暴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码// 1、首先在登录时,将 tokenValue 存储在本地,例如:
uni.setStorageSync('tokenValue', tokenValue);

// 2、在发起ajax请求的地方,获取这个值,并塞到header里
uni.request({
url: 'https://www.example.com/request', // 仅为示例,并非真实接口地址。
header: {
"content-type": "application/x-www-form-urlencoded",
"satoken": uni.getStorageSync('tokenValue') // 关键代码, 注意参数名字是 satoken
},
success: (res) => {
console.log(res.data);
}
});

方式2,更加灵活

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码// 1、首先在登录时,将tokenName和tokenValue一起存储在本地,例如:
uni.setStorageSync('tokenName', tokenName);
uni.setStorageSync('tokenValue', tokenValue);

// 2、在发起ajax的地方,获取这两个值, 并组织到head里
var tokenName = uni.getStorageSync('tokenName'); // 从本地缓存读取tokenName值
var tokenValue = uni.getStorageSync('tokenValue'); // 从本地缓存读取tokenValue值
var header = {
"content-type": "application/x-www-form-urlencoded" // 防止后台拿不到参数
};
if (tokenName != undefined && tokenName != '') {
header[tokenName] = tokenValue;
}

// 3、后续在发起请求时将 header 对象塞到请求头部
uni.request({
url: 'https://www.example.com/request', // 仅为示例,并非真实接口地址。
header: header,
success: (res) => {
console.log(res.data);
}
});

4.只要按照如此方法将token值传递到后端,sa-token就能像传统PC端一样自动读取到token值,进行鉴权
5.你可能会有疑问,难道我每个ajax都要写这么一坨?岂不是麻烦死了

  • 你当然不能每个ajax都写这么一坨,因为这种重复代码都是要封装在一个函数里统一调用的

其它解决方案?

如果你对Cookie非常了解,那你就会明白,所谓Cookie,本质上就是一个特殊的header参数而已

而既然它只是一个header参数,我们就能就能手动模拟实现它,从而完成鉴权操作

这其实是对无Cookie模式的另一种解决方案,有兴趣的同学可以百度了解一下,在此暂不赘述

写在最后

源码开源,作者不易,如果你喜欢这个框架麻烦你随手点一颗小星星哦!

  • 官网文档:sa-token.dev33.cn/
  • Gitee开源地址: gitee.com/sz6/sa-toke…
  • GitHub开源地址: github.com/click33/sa-…

在这里插入图片描述

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

架构图、用例图、流程图、时序图、类图

发表于 2021-03-08

在这里插入图片描述

文章目录

    • 前言
      • 架构图
      • 用例图
      • 流程图
      • 部署图
      • 类图
      • 时序图

前言


不想做架构师的程序员不是个好组长。

昨天给学长看了我的“架构图”之后,才知道那个不是架构图,差不多一半用例图加一半的流程图吧,贻笑大方了。

所以,知耻而后勇,太尴尬了。

这一篇我不打算写多少的文字,一切尽在不言中,一图顶千言。


架构图

前段时间收集了一些架构图,学长也给了我一个示范:

在这里插入图片描述

我找的示例:

在这里插入图片描述

参考资料:

软件架构概览

参与阶段:

需求分析阶段


用例图

用例图:用例图是指由参与者(Actor)、用例(Use Case),边界以及它们之间的关系构成的用于描述系统功能的视图。是系统的蓝图。

它主要描述系统功能,也就是从外部用户的角度观察,系统应该完成哪些功能,有利于开发人员以一种可视化的方式理解系统的功能需求。同时也是为了方便用户和系统分析人员的理解尽量一致,为用户和系统分析人员提供一个大众的,一致性的方法,准确地把握系统的需求,能够节省时间,提高效率!

在这里插入图片描述

在这里插入图片描述

参考资料:

用例

参与阶段:

需求分析阶段


流程图

什么是流程图就不多说了,高中数学必修三就有教了。

在这里插入图片描述

在这里插入图片描述

参与阶段:

需求分析阶段


部署图

部署图(deployment diagram,配置图)是用来显示系统中软件和硬件的物理架构。从部署图中,您可以了解到软件和硬件组件之间的物理关系以及处理节点的组件分布情况。使用部署图可以显示运行时系统的结构,同时还传达构成应用程序的硬件和软件元素的配置和部署方式。

在这里插入图片描述

在这里插入图片描述


接下来进入概要设计阶段


类图

类图:类图(Class diagram)是显示了模型的静态结构,特别是模型中存在的类、类的内部结构以及它们与其他类的关系等。

类图我多得很呐,写了那么多篇设计模式

在这里插入图片描述

参考资料:

UML类图快速上手

参与阶段:

概要设计阶段


接下来进入详细设计阶段


时序图

时序图:(Sequence Diagram),又名序列图、循序图,是一种UML交互图。它通过描述对象之间发送消息的时间顺序显示多个对象之间的动态协作。

在这里插入图片描述


结

建议收藏,划着划着可能就找不到了。

在这里插入图片描述

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

肝了好多天-动态规划十连-超细腻解析|刷题打卡

发表于 2021-03-08

【刷题打卡】周末肝了几道动态规划题,写一下我的心得笔记,故事开头,文章循序渐进,如果看官出现头疼不适,望休息,但是别放弃一定要看完!号外:每道题都有单元测试,看官们直接copy就可以debug了。

本文已更新到github

文末福利,最近整理一份面试资料《Java面试通关手册》,覆盖了Java核心技术、JVM、Java并发、SSM、微服务、数据库、数据结构等等。获取方式:GitHub github.com/Tingyu-Note…,更多内容陆续奉上。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…709710711…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%