This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,257 @@
<audio id="audio" title="03 | 字符串性能优化不容小觑百M内存轻松存储几十G数据" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/25/07/25e52e0c631df6e24c43c0eebc0e3d07.mp3"></audio>
你好,我是刘超。
从第二个模块开始我将带你学习Java编程的性能优化。今天我们就从最基础的String字符串优化讲起。
String对象是我们使用最频繁的一个对象类型但它的性能问题却是最容易被忽略的。String对象作为Java语言中重要的数据类型是内存中占据空间最大的一个对象。高效地使用字符串可以提升系统的整体性能。
接下来我们就从String对象的实现、特性以及实际使用中的优化这三个方面入手深入了解。
在开始之前,我想先问你一个小问题,也是我在招聘时,经常会问到面试者的一道题。虽是老生常谈了,但错误率依然很高,当然也有一些面试者答对了,但能解释清楚答案背后原理的人少之又少。问题如下:
通过三种不同的方式创建了三个对象,再依次两两匹配,每组被匹配的两个对象是否相等?代码如下:
```
String str1= &quot;abc&quot;;
String str2= new String(&quot;abc&quot;);
String str3= str2.intern();
assertSame(str1==str2);
assertSame(str2==str3);
assertSame(str1==str3)
```
你可以先想想答案,以及这样回答的原因。希望通过今天的学习,你能拿到满分。
## String对象是如何实现的
在Java语言中Sun公司的工程师们对String对象做了大量的优化来节约内存空间提升String对象在系统中的性能。一起来看看优化过程如下图所示
<img src="https://static001.geekbang.org/resource/image/35/6d/357f1cb1263fd0b5b3e4ccb6b971c96d.jpg" alt="">
**1.在Java6以及之前的版本中**String对象是对char数组进行了封装实现的对象主要有四个成员变量char数组、偏移量offset、字符数量count、哈希值hash。
String对象是通过offset和count两个属性来定位char[]数组,获取字符串。这么做可以高效、快速地共享数组对象,同时节省内存空间,但这种方式很有可能会导致内存泄漏。
**2.从Java7版本开始到Java8版本**Java对String类做了一些改变。String类中不再有offset和count两个变量了。这样的好处是String对象占用的内存稍微少了些同时String.substring方法也不再共享char[],从而解决了使用该方法可能导致的内存泄漏问题。
**3.从Java9版本开始**工程师将char[]字段改为了byte[]字段又维护了一个新的属性coder它是一个编码格式的标识。
工程师为什么这样修改呢?
我们知道一个char字符占16位2个字节。这个情况下存储单字节编码内的字符占一个字节的字符就显得非常浪费。JDK1.9的String类为了节约内存空间于是使用了占8位1个字节的byte数组来存放字符串。
而新属性coder的作用是在计算字符串长度或者使用indexOf函数时我们需要根据这个字段判断如何计算字符串长度。coder属性默认有0和1两个值0代表Latin-1单字节编码1代表UTF-16。如果String判断字符串只包含了Latin-1则coder属性值为0反之则为1。
## String对象的不可变性
了解了String对象的实现后你有没有发现在实现代码中String类被final关键字修饰了而且变量char数组也被final修饰了。
我们知道类被final修饰代表该类不可继承而char[]被final+private修饰代表了String对象不可被更改。Java实现的这个特性叫作String对象的不可变性即String对象一旦创建成功就不能再对它进行改变。
**Java这样做的好处在哪里呢**
第一保证String对象的安全性。假设String对象是可变的那么String对象将可能被恶意修改。
第二保证hash属性值不会频繁变更确保了唯一性使得类似HashMap容器才能实现相应的key-value缓存功能。
第三可以实现字符串常量池。在Java中通常有两种创建字符串对象的方式一种是通过字符串常量的方式创建如String str=“abc”另一种是字符串变量通过new形式的创建如String str = new String(“abc”)。
当代码中使用第一种方式创建字符串对象时JVM首先会检查该对象是否在字符串常量池中如果在就返回该对象引用否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建节约内存。
String str = new String(“abc”)这种方式,首先在编译类文件时,"abc"常量字符串将会放入到常量结构中在类加载时“abc"将会在常量池中创建其次在调用new时JVM命令将会调用String的构造函数同时引用常量池中的"abc” 字符串在堆内存中创建一个String对象最后str将引用String对象。
**这里附上一个你可能会想到的经典反例。**
平常编程时对一个String对象str赋值“hello”然后又让str值为“world”这个时候str的值变成了“world”。那么str值确实改变了为什么我还说String对象不可变呢
首先我来解释下什么是对象和对象引用。Java初学者往往对此存在误区特别是一些从PHP转Java的同学。在Java中要比较两个对象是否相等往往是用==而要判断两个对象的值是否相等则需要用equals方法来判断。
这是因为str只是String对象的引用并不是对象本身。对象在内存中是一块内存地址str则是一个指向该内存地址的引用。所以在刚刚我们说的这个例子中第一次赋值的时候创建了一个“hello”对象str引用指向“hello”地址第二次赋值的时候又重新创建了一个对象“world”str引用指向了“world”但“hello”对象依然存在于内存中。
也就是说str并不是对象而只是一个对象引用。真正的对象依然还在内存中没有被改变。
## String对象的优化
了解了String对象的实现原理和特性接下来我们就结合实际场景看看如何优化String对象的使用优化的过程中又有哪些需要注意的地方。
### 1.如何构建超大字符串?
编程过程中字符串的拼接很常见。前面我讲过String对象是不可变的如果我们使用String对象相加拼接我们想要的字符串是不是就会产生多个对象呢例如以下代码
```
String str= &quot;ab&quot; + &quot;cd&quot; + &quot;ef&quot;;
```
分析代码可知首先会生成ab对象再生成abcd对象最后生成abcdef对象从理论上来说这段代码是低效的。
但实际运行中,我们发现只有一个对象生成,这是为什么呢?难道我们的理论判断错了?我们再来看编译后的代码,你会发现编译器自动优化了这行代码,如下:
```
String str= &quot;abcdef&quot;;
```
上面我介绍的是字符串常量的累计,我们再来看看字符串变量的累计又是怎样的呢?
```
String str = &quot;abcdef&quot;;
for(int i=0; i&lt;1000; i++) {
str = str + i;
}
```
上面的代码编译后你可以看到编译器同样对这段代码进行了优化。不难发现Java在进行字符串的拼接时偏向使用StringBuilder这样可以提高程序的效率。
```
String str = &quot;abcdef&quot;;
for(int i=0; i&lt;1000; i++) {
str = (new StringBuilder(String.valueOf(str))).append(i).toString();
}
```
**综上已知:**即使使用+号作为字符串的拼接也一样可以被编译器优化成StringBuilder的方式。但再细致些你会发现在编译器优化的代码中每次循环都会生成一个新的StringBuilder实例同样也会降低系统的性能。
所以平时做字符串拼接的时候我建议你还是要显示地使用String Builder来提升系统性能。
如果在多线程编程中String对象的拼接涉及到线程安全你可以使用StringBuffer。但是要注意由于StringBuffer是线程安全的涉及到锁竞争所以从性能上来说要比StringBuilder差一些。
### 2.如何使用String.intern节省内存
讲完了构建字符串我们再来讨论下String对象的存储问题。先看一个案例。
Twitter每次发布消息状态的时候都会产生一个地址信息以当时Twitter用户的规模预估服务器需要32G的内存来存储地址信息。
```
public class Location {
private String city;
private String region;
private String countryCode;
private double longitude;
private double latitude;
}
```
考虑到其中有很多用户在地址信息上是有重合的,比如,国家、省份、城市等,这时就可以将这部分信息单独列出一个类,以减少重复,代码如下:
```
public class SharedLocation {
private String city;
private String region;
private String countryCode;
}
public class Location {
private SharedLocation sharedLocation;
double longitude;
double latitude;
}
```
通过优化数据存储大小减到了20G左右。但对于内存存储这个数据来说依然很大怎么办呢
这个案例来自一位Twitter工程师在QCon全球软件开发大会上的演讲他们想到的解决方法就是使用String.intern来节省内存空间从而优化String对象的存储。
具体做法就是在每次赋值的时候使用String的intern方法如果常量池中有相同值就会重复使用该对象返回对象引用这样一开始的对象就可以被回收掉。这种方式可以使重复性非常高的地址信息存储大小从20G降到几百兆。
```
SharedLocation sharedLocation = new SharedLocation();
sharedLocation.setCity(messageInfo.getCity().intern()); sharedLocation.setCountryCode(messageInfo.getRegion().intern());
sharedLocation.setRegion(messageInfo.getCountryCode().intern());
Location location = new Location();
location.set(sharedLocation);
location.set(messageInfo.getLongitude());
location.set(messageInfo.getLatitude());
```
**为了更好地理解,我们再来通过一个简单的例子,回顾下其中的原理:**
```
String a =new String(&quot;abc&quot;).intern();
String b = new String(&quot;abc&quot;).intern();
if(a==b) {
System.out.print(&quot;a==b&quot;);
}
```
输出结果:
```
a==b
```
在字符串常量中默认会将对象放入常量池在字符串变量中对象是会创建在堆内存中同时也会在常量池中创建一个字符串对象String对象中的char数组将会引用常量池中的char数组并返回堆内存对象引用。
如果调用intern方法会去查看字符串常量池中是否有等于该对象的字符串的引用如果没有在JDK1.6版本中会复制堆中的字符串到常量池中,并返回该字符串引用,堆内存中原有的字符串由于没有引用指向它,将会通过垃圾回收器回收。
在JDK1.7版本以后,由于常量池已经合并到了堆中,所以不会再复制具体字符串了,只是会把首次遇到的字符串的引用添加到常量池中;如果有,就返回常量池中的字符串引用。
了解了原理,我们再一起看下上边的例子。
在一开始字符串"abc"会在加载类时,在常量池中创建一个字符串对象。
创建a变量时调用new Sting()会在堆内存中创建一个String对象String对象中的char数组将会引用常量池中字符串。在调用intern方法之后会去常量池中查找是否有等于该字符串对象的引用有就返回引用。
创建b变量时调用new Sting()会在堆内存中创建一个String对象String对象中的char数组将会引用常量池中字符串。在调用intern方法之后会去常量池中查找是否有等于该字符串对象的引用有就返回引用。
而在堆内存中的两个对象由于没有引用指向它将会被垃圾回收。所以a和b引用的是同一个对象。
如果在运行时创建字符串对象将会直接在堆内存中创建不会在常量池中创建。所以动态创建的字符串对象调用intern方法在JDK1.6版本中会去常量池中创建运行时常量以及返回字符串引用在JDK1.7版本之后会将堆中的字符串常量的引用放入到常量池中当其它堆中的字符串对象通过intern方法获取字符串对象引用时则会去常量池中判断是否有相同值的字符串的引用此时有则返回该常量池中字符串引用跟之前的字符串指向同一地址的字符串对象。
以一张图来总结String字符串的创建分配内存地址情况
<img src="https://static001.geekbang.org/resource/image/b1/50/b1995253db45cd5e5b7bc1ded7cbdd50.jpg" alt="">
使用intern方法需要注意的一点是一定要结合实际场景。因为常量池的实现是类似于一个HashTable的实现方式HashTable存储的数据越大遍历的时间复杂度就会增加。如果数据过大会增加整个字符串常量池的负担。
### 3.如何使用字符串的分割方法?
最后我想跟你聊聊字符串的分割这种方法在编码中也很最常见。Split()方法使用了正则表达式实现了其强大的分割功能而正则表达式的性能是非常不稳定的使用不恰当会引起回溯问题很可能导致CPU居高不下。
所以我们应该慎重使用Split()方法我们可以用String.indexOf()方法代替Split()方法完成字符串的分割。如果实在无法满足需求你就在使用Split()方法时,对回溯问题加以重视就可以了。
## 总结
这一讲中我们认识到做好String字符串性能优化可以提高系统的整体性能。在这个理论基础上Java版本在迭代中通过不断地更改成员变量节约内存空间对String对象进行优化。
我们还特别提到了String对象的不可变性正是这个特性实现了字符串常量池通过减少同一个值的字符串对象的重复创建进一步节约内存。
但也是因为这个特性我们在做长字符串拼接时需要显示使用StringBuilder以提高字符串的拼接性能。最后在优化方面我们还可以使用intern方法让变量字符串对象重复使用常量池中相同值的对象进而节约内存。
最后再分享一个个人观点。那就是千里之堤,溃于蚁穴。日常编程中,我们往往可能就是对一个小小的字符串了解不够深入,使用不够恰当,从而引发线上事故。
比如在我之前的工作经历中就曾因为使用正则表达式对字符串进行匹配导致并发瓶颈这里也可以将其归纳为字符串使用的性能问题。具体实战分析我将在04讲中为你详解。
## 思考题
通过今天的学习,你知道文章开头那道面试题的答案了吗?背后的原理是什么?
## 互动时刻
今天除了思考题,我还想和你做一个简短的交流。
上两讲中,我收到了很多留言,在此非常感谢你的支持。由于前两讲是概述内容,主要是帮你建立对性能调优的整体认识,所以相对来说重理论、偏基础。但我发现,很多同学都有这样迫切的愿望,那就是赶紧学会使用排查工具,监测分析性能,解决当下的一些问题。
我这里特别想分享一点,其实性能调优不仅仅是学会使用排查监测工具,更重要的是掌握背后的调优原理,这样你不仅能够独立解决同一类的性能问题,还能写出高性能代码,所以我希望给你的学习路径是:夯实基础-结合实战-实现进阶。
最后,欢迎你积极发言,讨论思考题或是你遇到的性能问题都可以,我会知无不尽。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。

View File

@@ -0,0 +1,280 @@
<audio id="audio" title="04 | 慎重使用正则表达式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4a/dc/4acc227911497610cd1514a72efe02dc.mp3"></audio>
你好,我是刘超。
上一讲我在讲String对象优化时提到了Split()方法,该方法使用的正则表达式可能引起回溯问题,今天我们就来深入了解下,这究竟是怎么回事?
**开始之前,我们先来看一个案例,**可以帮助你更好地理解内容。
在一次小型项目开发中我遇到过这样一个问题。为了宣传新品我们开发了一个小程序按照之前评估的访问量这次活动预计参与用户量30W+TPS每秒事务处理量最高3000左右。
这个结果来自我对接口做的微基准性能测试。我习惯使用ab工具通过yum -y install httpd-tools可以快速安装在另一台机器上对http请求接口进行测试。
我可以通过设置-n请求数/-c并发用户数来模拟线上的峰值请求再通过TPS、RT每秒响应时间以及每秒请求时间分布情况这三个指标来衡量接口的性能如下图所示图中隐藏部分为我的服务器地址
<img src="https://static001.geekbang.org/resource/image/9c/1b/9c48880c13fd89bc48c0bd756a00561b.png" alt="">
就在做性能测试的时候我发现有一个提交接口的TPS一直上不去按理说这个业务非常简单存在性能瓶颈的可能性并不大。
我迅速使用了排除法查找问题。首先将方法里面的业务代码全部注释,留一个空方法在这里,再看性能如何。这种方式能够很好地区分是框架性能问题,还是业务代码性能问题。
我快速定位到了是业务代码问题就马上逐一查看代码查找原因。我将插入数据库操作代码加上之后TPS稍微下降了但还是没有找到原因。最后就只剩下Split() 方法操作了果然我将Split()方法加入之后TPS明显下降了。
可是一个Split()方法为什么会影响到TPS呢下面我们就来了解下正则表达式的相关内容学完了答案也就出来了。
## 什么是正则表达式?
很基础,这里带你简单回顾一下。
正则表达式是计算机科学的一个概念,很多语言都实现了它。正则表达式使用一些特定的元字符来检索、匹配以及替换符合规则的字符串。
构造正则表达式语法的元字符,由普通字符、标准字符、限定字符(量词)、定位字符(边界字符)组成。详情可见下图:
<img src="https://static001.geekbang.org/resource/image/6e/91/6ede246f783be477d3219f4218543691.jpg" alt="">
## 正则表达式引擎
正则表达式是一个用正则符号写出的公式,程序对这个公式进行语法分析,建立一个语法分析树,再根据这个分析树结合正则表达式的引擎生成执行程序(这个执行程序我们把它称作状态机,也叫状态自动机),用于字符匹配。
而这里的正则表达式引擎就是一套核心算法,用于建立状态机。
目前实现正则表达式引擎的方式有两种DFA自动机Deterministic Final Automaton 确定有限状态自动机和NFA自动机Non deterministic Finite Automaton 非确定有限状态自动机)。
对比来看构造DFA自动机的代价远大于NFA自动机但DFA自动机的执行效率高于NFA自动机。
假设一个字符串的长度是n如果用DFA自动机作为正则表达式引擎则匹配的时间复杂度为O(n)如果用NFA自动机作为正则表达式引擎由于NFA自动机在匹配过程中存在大量的分支和回溯假设NFA的状态数为s则该匹配算法的时间复杂度为Ons
NFA自动机的优势是支持更多功能。例如捕获group、环视、占有优先量词等高级功能。这些功能都是基于子表达式独立进行匹配因此在编程语言里使用的正则表达式库都是基于NFA实现的。
那么NFA自动机到底是怎么进行匹配的呢我以下面的字符和表达式来举例说明。
>
<p>text=“aabcab”<br>
regex=“bc”</p>
NFA自动机会读取正则表达式的每一个字符拿去和目标字符串匹配匹配成功就换正则表达式的下一个字符反之就继续和目标字符串的下一个字符进行匹配。分解一下过程。
首先读取正则表达式的第一个匹配符和字符串的第一个字符进行比较b对a不匹配继续换字符串的下一个字符也是a不匹配继续换下一个是b匹配。
<img src="https://static001.geekbang.org/resource/image/19/fa/197f80286625dc814b62a1220f14c0fa.jpg" alt="">
然后同理读取正则表达式的第二个匹配符和字符串的第四个字符进行比较c对c匹配继续读取正则表达式的下一个字符然而后面已经没有可匹配的字符了结束。
<img src="https://static001.geekbang.org/resource/image/93/25/93e48614363857393e75084b55b3e225.jpg" alt="">
这就是NFA自动机的匹配过程虽然在实际应用中碰到的正则表达式都要比这复杂但匹配方法是一样的。
### NFA自动机的回溯
用NFA自动机实现的比较复杂的正则表达式在匹配过程中经常会引起回溯问题。大量的回溯会长时间地占用CPU从而带来系统性能开销。我来举例说明。
>
<p>text=“abbc”<br>
regex=“ab{1,3}c”</p>
这个例子匹配目的比较简单。匹配以a开头以c结尾中间有1-3个b字符的字符串。NFA自动机对其解析的过程是这样的
首先读取正则表达式第一个匹配符a和字符串第一个字符a进行比较a对a匹配。
<img src="https://static001.geekbang.org/resource/image/2c/ae/2cb06df017f9e2974a8bd47c081196ae.jpg" alt="">
然后读取正则表达式第二个匹配符b{1,3} 和字符串的第二个字符b进行比较匹配。但因为 b{1,3} 表示1-3个b字符串NFA自动机又具有贪婪特性所以此时不会继续读取正则表达式的下一个匹配符而是依旧使用 b{1,3} 和字符串的第三个字符b进行比较结果还是匹配。
<img src="https://static001.geekbang.org/resource/image/dd/5d/dd5c24c6cfc5a11b133bdfcfb4c43b5d.jpg" alt="">
接着继续使用b{1,3} 和字符串的第四个字符c进行比较发现不匹配了此时就会发生回溯已经读取的字符串第四个字符c将被吐出去指针回到第三个字符b的位置。
<img src="https://static001.geekbang.org/resource/image/9f/e5/9f877bcafa908991a56b0262ed2990e5.jpg" alt="">
那么发生回溯以后匹配过程怎么继续呢程序会读取正则表达式的下一个匹配符c和字符串中的第四个字符c进行比较结果匹配结束。
<img src="https://static001.geekbang.org/resource/image/a6/22/a61f13e7540341ff064bf8d104069922.jpg" alt="">
### 如何减少回溯问题?
既然回溯会给系统带来性能开销那我们如何应对呢如果你有仔细看上面那个案例的话你会发现NFA自动机的贪婪特性就是导火索这和正则表达式的匹配模式息息相关一起来了解一下。
**1.贪婪模式Greedy**
顾名思义,就是在数量匹配中,如果单独使用+、 ? 、* 或{min,max} 等量词,正则表达式会匹配尽可能多的内容。
例如,上边那个例子:
>
<p>text=“abbc”<br>
regex=“ab{1,3}c”</p>
就是在贪婪模式下NFA自动机读取了最大的匹配范围即匹配3个b字符。匹配发生了一次失败就引起了一次回溯。如果匹配结果是“abbbc”就会匹配成功。
>
<p>text=“abbbc”<br>
regex=“ab{1,3}c”</p>
**2.懒惰模式Reluctant**
在该模式下,正则表达式会尽可能少地重复匹配字符。如果匹配成功,它会继续匹配剩余的字符串。
例如,在上面例子的字符后面加一个“?”,就可以开启懒惰模式。
>
<p>text=“abc”<br>
regex=“ab{1,3}?c”</p>
匹配结果是“abc”该模式下NFA自动机首先选择最小的匹配范围即匹配1个b字符因此就避免了回溯问题。
懒惰模式是无法完全避免回溯的,我们再通过一个例子来了解下懒惰模式在什么情况下会发生回溯问题。
>
<p>text=“abbc”<br>
regex=“ab{1,3}?c”</p>
以上匹配结果依然是成功的,这又是为什么呢?我们可以通过懒惰模式的匹配过程来了解下原因。
首先读取正则表达式第一个匹配符a和字符串第一个字符a进行比较a对a匹配。然后读取正则表达式第二个匹配符 b{1,3} 和字符串的第二个字符b进行比较匹配。
<img src="https://static001.geekbang.org/resource/image/fe/80/fed10b8310d8bc3873a2e94bcd848b80.png" alt="">
其次由于懒惰模式下正则表达式会尽可能少地重复匹配字符匹配字符串中的下一个匹配字符b不会继续与b{1,3}进行匹配从而选择放弃最大匹配b字符转而匹配正则表达式中的下一个字符c。
<img src="https://static001.geekbang.org/resource/image/10/3a/1022f60810980e0d9cdee7ace7c2813a.png" alt="">
此时你会发现匹配字符c与正则表达式中的字符c是不匹配的这个时候会发生一次回溯这次的回溯与贪婪模式中的回溯刚好相反懒惰模式的回溯是回溯正则表达式中一个匹配字符与上一个字符再进行匹配。如果匹配则将匹配字符串的下一个字符和正则表达式的下一个字符。
<img src="https://static001.geekbang.org/resource/image/10/dc/105badb94ecd9c3ed1b9c09c3ed25cdc.png" alt="">
**3.独占模式Possessive**
**同贪婪模式一样,独占模式一样会最大限度地匹配更多内容;不同的是,在独占模式下,匹配失败就会结束匹配,不会发生回溯问题。**
还是上边的例子,在字符后面加一个“+”,就可以开启独占模式。
>
<p>text=“abbc”<br>
regex=“ab{1,3}+bc”</p>
结果是不匹配,结束匹配,不会发生回溯问题。
同样,独占模式也不能避免回溯的发生,我们再拿最开始的这个例子来分析下:
>
<p>text=“abbc”<br>
regex=“ab{1,3}+c”</p>
结果是匹配的,这是因为**与贪婪模式一样,独占模式一样会最大限度地匹配更多内容,**即匹配完所有的b之后再去匹配c则匹配成功了。
讲到这里,你应该非常清楚了,在很多情况下使用懒惰模式和独占模式可以减少回溯的发生。
还有开头那道“一个split()方法为什么会影响到TPS”的存疑你应该也清楚了吧
我使用了split()方法提取域名并检查请求参数是否符合规定。split()在匹配分组时遇到特殊字符产生了大量回溯,我当时是在正则表达式后加了一个需要匹配的字符和“+”,解决了这个问题。
```
\\?(([A-Za-z0-9-~_=%]++\\&amp;{0,1})+)
```
## 正则表达式的优化
正则表达式带来的性能问题,给我敲了个警钟,在这里我也希望分享给你一些心得。任何一个细节问题,都有可能导致性能问题,而这背后折射出来的是我们对这项技术的了解不够透彻。所以我鼓励你学习性能调优,要掌握方法论,学会透过现象看本质。下面我就总结几种正则表达式的优化方法给你。
### 1.少用贪婪模式,多用独占模式
贪婪模式会引起回溯问题,我们可以使用独占模式来避免回溯。前面详解过了,这里我就不再解释了。
### 2.减少分支选择
分支选择类型“(X|Y|Z)”的正则表达式会降低性能,我们在开发的时候要尽量减少使用。如果一定要用,我们可以通过以下几种方式来优化:
首先,我们需要考虑选择的顺序,将比较常用的选择项放在前面,使它们可以较快地被匹配;
其次,我们可以尝试提取共用模式,例如,将“(abcd|abef)”替换为“ab(cd|ef)”后者匹配速度较快因为NFA自动机会尝试匹配ab如果没有找到就不会再尝试任何选项
最后如果是简单的分支选择类型我们可以用三次index代替“(X|Y|Z)”如果测试的话你就会发现三次index的效率要比“(X|Y|Z)”高出一些。
### 3.减少捕获嵌套
在讲这个方法之前,我先简单介绍下什么是捕获组和非捕获组。
捕获组是指把正则表达式中,子表达式匹配的内容保存到以数字编号或显式命名的数组中,方便后面引用。一般一个()就是一个捕获组,捕获组可以进行嵌套。
非捕获组则是指参与匹配却不进行分组编号的捕获组,其表达式一般由(?:exp组成。
在正则表达式中每个捕获组都有一个编号编号0代表整个匹配到的内容。我们可以看下面的例子
```
public static void main( String[] args )
{
String text = &quot;&lt;input high=\&quot;20\&quot; weight=\&quot;70\&quot;&gt;test&lt;/input&gt;&quot;;
String reg=&quot;(&lt;input.*?&gt;)(.*?)(&lt;/input&gt;)&quot;;
Pattern p = Pattern.compile(reg);
Matcher m = p.matcher(text);
while(m.find()) {
System.out.println(m.group(0));//整个匹配到的内容
System.out.println(m.group(1));//(&lt;input.*?&gt;)
System.out.println(m.group(2));//(.*?)
System.out.println(m.group(3));//(&lt;/input&gt;)
}
}
```
运行结果:
```
&lt;input high=\&quot;20\&quot; weight=\&quot;70\&quot;&gt;test&lt;/input&gt;
&lt;input high=\&quot;20\&quot; weight=\&quot;70\&quot;&gt;
test
&lt;/input&gt;
```
如果你并不需要获取某一个分组内的文本,那么就使用非捕获分组。例如,使用“(?:X)”代替“(X)”,我们再看下面的例子:
```
public static void main( String[] args )
{
String text = &quot;&lt;input high=\&quot;20\&quot; weight=\&quot;70\&quot;&gt;test&lt;/input&gt;&quot;;
String reg=&quot;(?:&lt;input.*?&gt;)(.*?)(?:&lt;/input&gt;)&quot;;
Pattern p = Pattern.compile(reg);
Matcher m = p.matcher(text);
while(m.find()) {
System.out.println(m.group(0));//整个匹配到的内容
System.out.println(m.group(1));//(.*?)
}
}
```
运行结果:
```
&lt;input high=\&quot;20\&quot; weight=\&quot;70\&quot;&gt;test&lt;/input&gt;
test
```
综上可知:减少不需要获取的分组,可以提高正则表达式的性能。
## 总结
正则表达式虽然小,却有着强大的匹配功能。我们经常用到它,比如,注册页面手机号或邮箱的校验。
但很多时候,我们又会因为它小而忽略它的使用规则,测试用例中又没有覆盖到一些特殊用例,不乏上线就中招的情况发生。
综合我以往的经验来看,如果使用正则表达式能使你的代码简洁方便,那么在做好性能排查的前提下,可以去使用;如果不能,那么正则表达式能不用就不用,以此避免造成更多的性能问题。
## 思考题
除了Split()方法使用到正则表达式其实Java还有一些方法也使用了正则表达式去实现一些功能使我们很容易掉入陷阱。现在就请你想一想JDK里面还有哪些工具方法用到了正则表达式
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
<img src="https://static001.geekbang.org/resource/image/bb/67/bbe343640d6b708832c4133ec53ed967.jpg" alt="unpreview">

View File

@@ -0,0 +1,432 @@
<audio id="audio" title="05 | ArrayList还是LinkedList使用不当性能差千倍" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ad/b6/ad7930998d431602b4ca022d551fc5b6.mp3"></audio>
你好,我是刘超。
集合作为一种存储数据的容器是我们日常开发中使用最频繁的对象类型之一。JDK为开发者提供了一系列的集合类型这些集合类型使用不同的数据结构来实现。因此不同的集合类型使用场景也不同。
很多同学在面试的时候经常会被问到集合的相关问题比较常见的有ArrayList和LinkedList的区别。
相信大部分同学都能回答上“ArrayList是基于数组实现LinkedList是基于链表实现。”
而在回答使用场景的时候我发现大部分同学的答案是“ArrayList和LinkedList在新增、删除元素时LinkedList的效率要高于 ArrayList而在遍历的时候ArrayList的效率要高于LinkedList。”这个回答是否准确呢今天这一讲就带你验证。
## 初识List接口
在学习List集合类之前我们先来通过这张图看下List集合类的接口和类的实现关系
<img src="https://static001.geekbang.org/resource/image/ab/36/ab73021caa4545ae0ac917e0f36cde36.jpg" alt="">
我们可以看到ArrayList、Vector、LinkedList集合类继承了AbstractList抽象类而AbstractList实现了List接口同时也继承了AbstractCollection抽象类。ArrayList、Vector、LinkedList又根据自我定位分别实现了各自的功能。
ArrayList和Vector使用了数组实现这两者的实现原理差不多LinkedList使用了双向链表实现。基础铺垫就到这里接下来我们就详细地分析下ArrayList和LinkedList的源码实现。
## ArrayList是如何实现的
ArrayList很常用先来几道测试题自检下你对ArrayList的了解程度。
**问题1**我们在查看ArrayList的实现类源码时你会发现对象数组elementData使用了transient修饰我们知道transient关键字修饰该属性则表示该属性不会被序列化然而我们并没有看到文档中说明ArrayList不能被序列化这是为什么
**问题2**我们在使用ArrayList进行新增、删除时经常被提醒“使用ArrayList做新增删除操作会影响效率”。那是不是ArrayList在大量新增元素的场景下效率就一定会变慢呢
**问题3**如果让你使用for循环以及迭代循环遍历一个ArrayList你会使用哪种方式呢原因是什么
如果你对这几道测试都没有一个全面的了解那就跟我一起从数据结构、实现原理以及源码角度重新认识下ArrayList吧。
### 1.ArrayList实现类
ArrayList实现了List接口继承了AbstractList抽象类底层是数组实现的并且实现了自增扩容数组大小。
ArrayList还实现了Cloneable接口和Serializable接口所以他可以实现克隆和序列化。
ArrayList还实现了RandomAccess接口。你可能对这个接口比较陌生不知道具体的用处。通过代码我们可以发现这个接口其实是一个空接口什么也没有实现那ArrayList为什么要去实现它呢
其实RandomAccess接口是一个标志接口他标志着“只要实现该接口的List类都能实现快速随机访问”。
```
public class ArrayList&lt;E&gt; extends AbstractList&lt;E&gt;
implements List&lt;E&gt;, RandomAccess, Cloneable, java.io.Serializable
```
### 2.ArrayList属性
ArrayList属性主要由数组长度size、对象数组elementData、初始化容量default_capacity等组成 其中初始化容量默认大小为10。
```
//默认初始化容量
private static final int DEFAULT_CAPACITY = 10;
//对象数组
transient Object[] elementData;
//数组长度
private int size;
```
从ArrayList属性来看它没有被任何的多线程关键字修饰但elementData被关键字transient修饰了。这就是我在上面提到的第一道测试题transient关键字修饰该字段则表示该属性不会被序列化但ArrayList其实是实现了序列化接口这到底是怎么回事呢
这还得从“ArrayList是基于数组实现“开始说起由于ArrayList的数组是基于动态扩增的所以并不是所有被分配的内存空间都存储了数据。
如果采用外部序列化法实现数组的序列化会序列化整个数组。ArrayList为了避免这些没有存储数据的内存空间被序列化内部提供了两个私有方法writeObject以及readObject来自我完成序列化与反序列化从而在序列化与反序列化数组时节省了空间和时间。
因此使用transient修饰数组是防止对象数组被其他外部方法序列化。
### 3.ArrayList构造函数
ArrayList类实现了三个构造函数第一个是创建ArrayList对象时传入一个初始化值第二个是默认创建一个空数组对象第三个是传入一个集合类型进行初始化。
当ArrayList新增元素时如果所存储的元素已经超过其已有大小它会计算元素大小后再进行动态扩容数组的扩容会导致整个数组进行一次内存复制。因此我们在初始化ArrayList时可以通过第一个构造函数合理指定数组初始大小这样有助于减少数组的扩容次数从而提高系统性能。
```
public ArrayList(int initialCapacity) {
//初始化容量不为零时,将根据初始化值创建数组大小
if (initialCapacity &gt; 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {//初始化容量为零时,使用默认的空数组
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException(&quot;Illegal Capacity: &quot;+
initialCapacity);
}
}
public ArrayList() {
//初始化默认为空数组
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
```
### 4.ArrayList新增元素
ArrayList新增元素的方法有两种一种是直接将元素加到数组的末尾另外一种是添加元素到任意位置。
```
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
```
两个方法的相同之处是在添加元素之前都会先确认容量大小如果容量够大就不用进行扩容如果容量不够大就会按照原来数组的1.5倍大小进行扩容,在扩容之后需要将数组复制到新分配的内存地址。
```
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length &gt; 0)
grow(minCapacity);
}
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity &gt;&gt; 1);
if (newCapacity - minCapacity &lt; 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE &gt; 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
```
当然,两个方法也有不同之处,添加元素到任意位置,会导致在该位置后的所有元素都需要重新排列,而将元素添加到数组的末尾,在没有发生扩容的前提下,是不会有元素复制排序过程的。
这里你就可以找到第二道测试题的答案了。如果我们在初始化时就比较清楚存储数据的大小就可以在ArrayList初始化时指定数组容量大小并且在添加元素时只在数组末尾添加元素那么ArrayList在大量新增元素的场景下性能并不会变差反而比其他List集合的性能要好。
### 5.ArrayList删除元素
ArrayList的删除方法和添加任意位置元素的方法是有些相同的。ArrayList在每一次有效的删除元素操作之后都要进行数组的重组并且删除的元素位置越靠前数组重组的开销就越大。
```
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved &gt; 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
```
### 6.ArrayList遍历元素
由于ArrayList是基于数组实现的所以在获取元素的时候是非常快捷的。
```
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
E elementData(int index) {
return (E) elementData[index];
}
```
## LinkedList是如何实现的
虽然LinkedList与ArrayList都是List类型的集合但LinkedList的实现原理却和ArrayList大相径庭使用场景也不太一样。
LinkedList是基于双向链表数据结构实现的LinkedList定义了一个Node结构Node结构中包含了3个部分元素内容item、前指针prev以及后指针next代码如下。
```
private static class Node&lt;E&gt; {
E item;
Node&lt;E&gt; next;
Node&lt;E&gt; prev;
Node(Node&lt;E&gt; prev, E element, Node&lt;E&gt; next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
```
总结一下LinkedList就是由Node结构对象连接而成的一个双向链表。在JDK1.7之前LinkedList中只包含了一个Entry结构的header属性并在初始化的时候默认创建一个空的Entry用来做header前后指针指向自己形成一个循环双向链表。
在JDK1.7之后LinkedList做了很大的改动对链表进行了优化。链表的Entry结构换成了Node内部组成基本没有改变但LinkedList里面的header属性去掉了新增了一个Node结构的first属性和一个Node结构的last属性。这样做有以下几点好处
- first/last属性能更清晰地表达链表的链头和链尾概念
- first/last方式可以在初始化LinkedList的时候节省new一个Entry
- first/last方式最重要的性能优化是链头和链尾的插入删除操作更加快捷了。
这里同ArrayList的讲解一样我将从数据结构、实现原理以及源码分析等几个角度带你深入了解LinkedList。
### 1.LinkedList实现类
LinkedList类实现了List接口、Deque接口同时继承了AbstractSequentialList抽象类LinkedList既实现了List类型又有Queue类型的特点LinkedList也实现了Cloneable和Serializable接口同ArrayList一样可以实现克隆和序列化。
由于LinkedList存储数据的内存地址是不连续的而是通过指针来定位不连续地址因此LinkedList不支持随机快速访问LinkedList也就不能实现RandomAccess接口。
```
public class LinkedList&lt;E&gt;
extends AbstractSequentialList&lt;E&gt;
implements List&lt;E&gt;, Deque&lt;E&gt;, Cloneable, java.io.Serializable
```
### 2.LinkedList属性
我们前面讲到了LinkedList的两个重要属性first/last属性其实还有一个size属性。我们可以看到这三个属性都被transient修饰了原因很简单我们在序列化的时候不会只对头尾进行序列化所以LinkedList也是自行实现readObject和writeObject进行序列化与反序列化。
```
transient int size = 0;
transient Node&lt;E&gt; first;
transient Node&lt;E&gt; last;
```
### 3.LinkedList新增元素
LinkedList添加元素的实现很简洁但添加的方式却有很多种。默认的add (Ee)方法是将添加的元素加到队尾首先是将last元素置换到临时变量中生成一个新的Node节点对象然后将last引用指向新节点对象之前的last对象的前指针指向新节点对象。
```
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node&lt;E&gt; l = last;
final Node&lt;E&gt; newNode = new Node&lt;&gt;(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
```
LinkedList也有添加元素到任意位置的方法如果我们是将元素添加到任意两个元素的中间位置添加元素操作只会改变前后元素的前后指针指针将会指向添加的新元素所以相比ArrayList的添加操作来说LinkedList的性能优势明显。
```
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
void linkBefore(E e, Node&lt;E&gt; succ) {
// assert succ != null;
final Node&lt;E&gt; pred = succ.prev;
final Node&lt;E&gt; newNode = new Node&lt;&gt;(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}
```
### 4.LinkedList删除元素
在LinkedList删除元素的操作中我们首先要通过循环找到要删除的元素如果要删除的位置处于List的前半段就从前往后找若其位置处于后半段就从后往前找。
这样做的话无论要删除较为靠前或较为靠后的元素都是非常高效的但如果List拥有大量元素移除的元素又在List的中间段那效率相对来说会很低。
### 5.LinkedList遍历元素
LinkedList的获取元素操作实现跟LinkedList的删除元素操作基本类似通过分前后半段来循环查找到对应的元素。但是通过这种方式来查询元素是非常低效的特别是在for循环遍历的情况下每一次循环都会去遍历半个List。
所以在LinkedList循环遍历时我们可以使用iterator方式迭代循环直接拿到我们的元素而不需要通过循环查找List。
## 总结
前面我们已经从源码的实现角度深入了解了ArrayList和LinkedList的实现原理以及各自的特点。如果你能充分理解这些内容很多实际应用中的相关性能问题也就迎刃而解了。
就像如果现在还有人跟你说“ArrayList和LinkedList在新增、删除元素时LinkedList的效率要高于ArrayList而在遍历的时候ArrayList的效率要高于LinkedList”你还会表示赞同吗
现在我们不妨通过几组测试来验证一下。这里因为篇幅限制,所以我就直接给出测试结果了,对应的测试代码你可以访问[Github](https://github.com/nickliuchao/collection)查看和下载。
**1.ArrayList和LinkedList新增元素操作测试**
- 从集合头部位置新增元素
- 从集合中间位置新增元素
- 从集合尾部位置新增元素
测试结果(花费时间)
- ArrayList&gt;LinkedList
- ArrayList&lt;LinkedList
- ArrayList&lt;LinkedList
通过这组测试我们可以知道LinkedList添加元素的效率未必要高于ArrayList。
由于ArrayList是数组实现的而数组是一块连续的内存空间在添加元素到数组头部的时候需要对头部以后的数据进行复制重排所以效率很低而LinkedList是基于链表实现在添加元素的时候首先会通过循环查找到添加元素的位置如果要添加的位置处于List的前半段就从前往后找若其位置处于后半段就从后往前找。因此LinkedList添加元素到头部是非常高效的。
同上可知ArrayList在添加元素到数组中间时同样有部分数据需要复制重排效率也不是很高LinkedList将元素添加到中间位置是添加元素最低效率的因为靠近中间位置在添加元素之前的循环查找是遍历元素最多的操作。
而在添加元素到尾部的操作中我们发现在没有扩容的情况下ArrayList的效率要高于LinkedList。这是因为ArrayList在添加元素到尾部的时候不需要复制重排数据效率非常高。而LinkedList虽然也不用循环查找元素但LinkedList中多了new对象以及变换指针指向对象的过程所以效率要低于ArrayList。
说明一下这里我是基于ArrayList初始化容量足够排除动态扩容数组容量的情况下进行的测试如果有动态扩容的情况ArrayList的效率也会降低。
**2.ArrayList和LinkedList删除元素操作测试**
- 从集合头部位置删除元素
- 从集合中间位置删除元素
- 从集合尾部位置删除元素
测试结果(花费时间)
- ArrayList&gt;LinkedList
- ArrayList&lt;LinkedList
- ArrayList&lt;LinkedList
ArrayList和LinkedList删除元素操作测试的结果和添加元素操作测试的结果很接近这是一样的原理我在这里就不重复讲解了。
**3.ArrayList和LinkedList遍历元素操作测试**
- for(;;)循环
- 迭代器迭代循环
测试结果(花费时间)
- ArrayList&lt;LinkedList
- ArrayList≈LinkedList
我们可以看到LinkedList的for循环性能是最差的而ArrayList的for循环性能是最好的。
这是因为LinkedList基于链表实现的在使用for循环的时候每一次for循环都会去遍历半个List所以严重影响了遍历的效率ArrayList则是基于数组实现的并且实现了RandomAccess接口标志意味着ArrayList可以实现快速随机访问所以for循环效率非常高。
LinkedList的迭代循环遍历和ArrayList的迭代循环遍历性能相当也不会太差所以在遍历LinkedList时我们要切忌使用for循环遍历。
## 思考题
我们通过一个使用for循环遍历删除操作ArrayList数组的例子思考下ArrayList数组的删除操作应该注意的一些问题。
```
public static void main(String[] args)
{
ArrayList&lt;String&gt; list = new ArrayList&lt;String&gt;();
list.add(&quot;a&quot;);
list.add(&quot;a&quot;);
list.add(&quot;b&quot;);
list.add(&quot;b&quot;);
list.add(&quot;c&quot;);
list.add(&quot;c&quot;);
remove(list);//删除指定的“b”元素
for(int i=0; i&lt;list.size(); i++)(&quot;c&quot;)()()(s : list)
{
System.out.println(&quot;element : &quot; + s)list.get(i)
}
}
```
从上面的代码来看我定义了一个ArrayList数组里面添加了一些元素然后我通过remove删除指定的元素。请问以下两种写法哪种是正确的
写法1
```
public static void remove(ArrayList&lt;String&gt; list)
{
Iterator&lt;String&gt; it = list.iterator();
while (it.hasNext()) {
String str = it.next();
if (str.equals(&quot;b&quot;)) {
it.remove();
}
}
}
```
写法2
```
public static void remove(ArrayList&lt;String&gt; list)
{
for (String s : list)
{
if (s.equals(&quot;b&quot;))
{
list.remove(s);
}
}
}
```
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。

View File

@@ -0,0 +1,359 @@
<audio id="audio" title="06 | Stream如何提高遍历集合效率" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/69/4a/6930c5ea8503c9740ca43e84fa38574a.mp3"></audio>
你好,我是刘超。
上一讲中我在讲List集合类那我想你一定也知道集合的顶端接口Collection。在Java8中Collection新增了两个流方法分别是Stream()和parallelStream()。
通过英文名不难猜测这两个方法肯定和Stream有关那进一步猜测是不是和我们熟悉的InputStream和OutputStream也有关系呢集合类中新增的两个Stream方法到底有什么作用今天我们就来深入了解下Stream。
## 什么是Stream
现在很多大数据量系统中都存在分表分库的情况。
例如电商系统中的订单表常常使用用户ID的Hash值来实现分表分库这样是为了减少单个表的数据量优化用户查询订单的速度。
但在后台管理员审核订单时,他们需要将各个数据源的数据查询到应用层之后进行合并操作。
例如,当我们需要查询出过滤条件下的所有订单,并按照订单的某个条件进行排序,单个数据源查询出来的数据是可以按照某个条件进行排序的,但多个数据源查询出来已经排序好的数据,并不代表合并后是正确的排序,所以我们需要在应用层对合并数据集合重新进行排序。
在Java8之前我们通常是通过for循环或者Iterator迭代来重新排序合并数据又或者通过重新定义Collections.sorts的Comparator方法来实现这两种方式对于大数据量系统来说效率并不是很理想。
Java8中添加了一个新的接口类Stream他和我们之前接触的字节流概念不太一样Java8集合中的Stream相当于高级版的Iterator他可以通过Lambda 表达式对集合进行各种非常便利、高效的聚合操作Aggregate Operation或者大批量数据操作 (Bulk Data Operation)。
Stream的聚合操作与数据库SQL的聚合操作sorted、filter、map等类似。我们在应用层就可以高效地实现类似数据库SQL的聚合操作了而在数据操作方面Stream不仅可以通过串行的方式实现数据操作还可以通过并行的方式处理大批量数据提高数据的处理效率。
**接下来我们就用一个简单的例子来体验下Stream的简洁与强大。**
这个Demo的需求是过滤分组一所中学里身高在160cm以上的男女同学我们先用传统的迭代方式来实现代码如下
```
Map&lt;String, List&lt;Student&gt;&gt; stuMap = new HashMap&lt;String, List&lt;Student&gt;&gt;();
for (Student stu: studentsList) {
if (stu.getHeight() &gt; 160) { //如果身高大于160
if (stuMap.get(stu.getSex()) == null) { //该性别还没分类
List&lt;Student&gt; list = new ArrayList&lt;Student&gt;(); //新建该性别学生的列表
list.add(stu);//将学生放进去列表
stuMap.put(stu.getSex(), list);//将列表放到map中
} else { //该性别分类已存在
stuMap.get(stu.getSex()).add(stu);//该性别分类已存在,则直接放进去即可
}
}
}
```
我们再使用Java8中的Stream API进行实现
1.串行实现
```
Map&lt;String, List&lt;Student&gt;&gt; stuMap = stuList.stream().filter((Student s) -&gt; s.getHeight() &gt; 160) .collect(Collectors.groupingBy(Student ::getSex));
```
2.并行实现
```
Map&lt;String, List&lt;Student&gt;&gt; stuMap = stuList.parallelStream().filter((Student s) -&gt; s.getHeight() &gt; 160) .collect(Collectors.groupingBy(Student ::getSex));
```
通过上面两个简单的例子我们可以发现Stream结合Lambda表达式实现遍历筛选功能非常得简洁和便捷。
## Stream如何优化遍历
上面我们初步了解了Java8中的Stream API那Stream是如何做到优化迭代的呢并行又是如何实现的下面我们就透过Stream源码剖析Stream的实现原理。
### 1.Stream操作分类
在了解Stream的实现原理之前我们先来了解下Stream的操作分类因为他的操作分类其实是实现高效迭代大数据集合的重要原因之一。为什么这样说分析完你就清楚了。
官方将Stream中的操作分为两大类中间操作Intermediate operations和终结操作Terminal operations。中间操作只对操作进行了记录即只会返回一个流不会进行计算操作而终结操作是实现了计算操作。
中间操作又可以分为无状态Stateless与有状态Stateful操作前者是指元素的处理不受之前元素的影响后者是指该操作只有拿到所有元素之后才能继续下去。
终结操作又可以分为短路Short-circuiting与非短路Unshort-circuiting操作前者是指遇到某些符合条件的元素就可以得到最终结果后者是指必须处理完所有元素才能得到最终结果。操作分类详情如下图所示
<img src="https://static001.geekbang.org/resource/image/ea/94/ea8dfeebeae8f05ae809ee61b3bf3094.jpg" alt="">
我们通常还会将中间操作称为懒操作也正是由这种懒操作结合终结操作、数据源构成的处理管道Pipeline实现了Stream的高效。
### 2.Stream源码实现
在了解Stream如何工作之前我们先来了解下Stream包是由哪些主要结构类组合而成的各个类的职责是什么。参照下图
<img src="https://static001.geekbang.org/resource/image/fc/00/fc256f9f8f9e3224aac10b2ee8940e00.jpg" alt="">
BaseStream和Stream为最顶端的接口类。BaseStream主要定义了流的基本接口方法例如spliterator、isParallel等Stream则定义了一些流的常用操作方法例如map、filter等。
ReferencePipeline是一个结构类他通过定义内部类组装了各种操作流。他定义了Head、StatelessOp、StatefulOp三个内部类实现了BaseStream与Stream的接口方法。
Sink接口是定义每个Stream操作之间关系的协议他包含begin()、end()、cancellationRequested()、accpt()四个方法。ReferencePipeline最终会将整个Stream流操作组装成一个调用链而这条调用链上的各个Stream操作的上下关系就是通过Sink接口协议来定义实现的。
### 3.Stream操作叠加
我们知道一个Stream的各个操作是由处理管道组装并统一完成数据处理的。在JDK中每次的中断操作会以使用阶段Stage命名。
管道结构通常是由ReferencePipeline类实现的前面讲解Stream包结构时我提到过ReferencePipeline包含了Head、StatelessOp、StatefulOp三种内部类。
Head类主要用来定义数据源操作在我们初次调用names.stream()方法时会初次加载Head对象此时为加载数据源操作接着加载的是中间操作分别为无状态中间操作StatelessOp对象和有状态操作StatefulOp对象此时的Stage并没有执行而是通过AbstractPipeline生成了一个中间操作Stage链表当我们调用终结操作时会生成一个最终的Stage通过这个Stage触发之前的中间操作从最后一个Stage开始递归产生一个Sink链。如下图所示
<img src="https://static001.geekbang.org/resource/image/f5/19/f548ce93fef2d41b03274295aa0a0419.jpg" alt="">
**下面我们再通过一个例子来感受下Stream的操作分类是如何实现高效迭代大数据集合的。**
```
List&lt;String&gt; names = Arrays.asList(&quot;张三&quot;, &quot;李四&quot;, &quot;王老五&quot;, &quot;李三&quot;, &quot;刘老四&quot;, &quot;王小二&quot;, &quot;张四&quot;, &quot;张五六七&quot;);
String maxLenStartWithZ = names.stream()
.filter(name -&gt; name.startsWith(&quot;张&quot;))
.mapToInt(String::length)
.max()
.toString();
```
这个例子的需求是查找出一个长度最长并且以张为姓氏的名字。从代码角度来看你可能会认为是这样的操作流程首先遍历一次集合得到以“张”开头的所有名字然后遍历一次filter得到的集合将名字转换成数字长度最后再从长度集合中找到最长的那个名字并且返回。
这里我要很明确地告诉你,实际情况并非如此。我们来逐步分析下这个方法里所有的操作是如何执行的。
首先 因为names是ArrayList集合所以names.stream()方法将会调用集合类基础接口Collection的Stream方法
```
default Stream&lt;E&gt; stream() {
return StreamSupport.stream(spliterator(), false);
}
```
然后Stream方法就会调用StreamSupport类的Stream方法方法中初始化了一个ReferencePipeline的Head内部类对象
```
public static &lt;T&gt; Stream&lt;T&gt; stream(Spliterator&lt;T&gt; spliterator, boolean parallel) {
Objects.requireNonNull(spliterator);
return new ReferencePipeline.Head&lt;&gt;(spliterator,
StreamOpFlag.fromCharacteristics(spliterator),
parallel);
}
```
再调用filter和map方法这两个方法都是无状态的中间操作所以执行filter和map操作时并没有进行任何的操作而是分别创建了一个Stage来标识用户的每一次操作。
而通常情况下Stream的操作又需要一个回调函数所以一个完整的Stage是由数据来源、操作、回调函数组成的三元组来表示。如下图所示分别是ReferencePipeline的filter方法和map方法
```
@Override
public final Stream&lt;P_OUT&gt; filter(Predicate&lt;? super P_OUT&gt; predicate) {
Objects.requireNonNull(predicate);
return new StatelessOp&lt;P_OUT, P_OUT&gt;(this, StreamShape.REFERENCE,
StreamOpFlag.NOT_SIZED) {
@Override
Sink&lt;P_OUT&gt; opWrapSink(int flags, Sink&lt;P_OUT&gt; sink) {
return new Sink.ChainedReference&lt;P_OUT, P_OUT&gt;(sink) {
@Override
public void begin(long size) {
downstream.begin(-1);
}
@Override
public void accept(P_OUT u) {
if (predicate.test(u))
downstream.accept(u);
}
};
}
};
}
```
```
@Override
@SuppressWarnings(&quot;unchecked&quot;)
public final &lt;R&gt; Stream&lt;R&gt; map(Function&lt;? super P_OUT, ? extends R&gt; mapper) {
Objects.requireNonNull(mapper);
return new StatelessOp&lt;P_OUT, R&gt;(this, StreamShape.REFERENCE,
StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
@Override
Sink&lt;P_OUT&gt; opWrapSink(int flags, Sink&lt;R&gt; sink) {
return new Sink.ChainedReference&lt;P_OUT, R&gt;(sink) {
@Override
public void accept(P_OUT u) {
downstream.accept(mapper.apply(u));
}
};
}
};
}
```
new StatelessOp将会调用父类AbstractPipeline的构造函数这个构造函数将前后的Stage联系起来生成一个Stage链表
```
AbstractPipeline(AbstractPipeline&lt;?, E_IN, ?&gt; previousStage, int opFlags) {
if (previousStage.linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
previousStage.linkedOrConsumed = true;
previousStage.nextStage = this;//将当前的stage的next指针指向之前的stage
this.previousStage = previousStage;//赋值当前stage当全局变量previousStage
this.sourceOrOpFlags = opFlags &amp; StreamOpFlag.OP_MASK;
this.combinedFlags = StreamOpFlag.combineOpFlags(opFlags, previousStage.combinedFlags);
this.sourceStage = previousStage.sourceStage;
if (opIsStateful())
sourceStage.sourceAnyStateful = true;
this.depth = previousStage.depth + 1;
}
```
因为在创建每一个Stage时都会包含一个opWrapSink()方法该方法会把一个操作的具体实现封装在Sink类中Sink采用处理-&gt;转发)的模式来叠加操作。
当执行max方法时会调用ReferencePipeline的max方法此时由于max方法是终结操作所以会创建一个TerminalOp操作同时创建一个ReducingSink并且将操作封装在Sink类中。
```
@Override
public final Optional&lt;P_OUT&gt; max(Comparator&lt;? super P_OUT&gt; comparator) {
return reduce(BinaryOperator.maxBy(comparator));
}
```
最后调用AbstractPipeline的wrapSink方法该方法会调用opWrapSink生成一个Sink链表Sink链表中的每一个Sink都封装了一个操作的具体实现。
```
@Override
@SuppressWarnings(&quot;unchecked&quot;)
final &lt;P_IN&gt; Sink&lt;P_IN&gt; wrapSink(Sink&lt;E_OUT&gt; sink) {
Objects.requireNonNull(sink);
for ( @SuppressWarnings(&quot;rawtypes&quot;) AbstractPipeline p=AbstractPipeline.this; p.depth &gt; 0; p=p.previousStage) {
sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
}
return (Sink&lt;P_IN&gt;) sink;
}
```
当Sink链表生成完成后Stream开始执行通过spliterator迭代集合执行Sink链表中的具体操作。
```
@Override
final &lt;P_IN&gt; void copyInto(Sink&lt;P_IN&gt; wrappedSink, Spliterator&lt;P_IN&gt; spliterator) {
Objects.requireNonNull(wrappedSink);
if (!StreamOpFlag.SHORT_CIRCUIT.isKnown(getStreamAndOpFlags())) {
wrappedSink.begin(spliterator.getExactSizeIfKnown());
spliterator.forEachRemaining(wrappedSink);
wrappedSink.end();
}
else {
copyIntoWithCancel(wrappedSink, spliterator);
}
}
```
Java8中的Spliterator的forEachRemaining会迭代集合每迭代一次都会执行一次filter操作如果filter操作通过就会触发map操作然后将结果放入到临时数组object中再进行下一次的迭代。完成中间操作后就会触发终结操作max。
这就是串行处理方式了那么Stream的另一种处理数据的方式又是怎么操作的呢
### 4.Stream并行处理
Stream处理数据的方式有两种串行处理和并行处理。要实现并行处理我们只需要在例子的代码中新增一个Parallel()方法,代码如下所示:
```
List&lt;String&gt; names = Arrays.asList(&quot;张三&quot;, &quot;李四&quot;, &quot;王老五&quot;, &quot;李三&quot;, &quot;刘老四&quot;, &quot;王小二&quot;, &quot;张四&quot;, &quot;张五六七&quot;);
String maxLenStartWithZ = names.stream()
.parallel()
.filter(name -&gt; name.startsWith(&quot;张&quot;))
.mapToInt(String::length)
.max()
.toString();
```
Stream的并行处理在执行终结操作之前跟串行处理的实现是一样的。而在调用终结方法之后实现的方式就有点不太一样会调用TerminalOp的evaluateParallel方法进行并行处理。
```
final &lt;R&gt; R evaluate(TerminalOp&lt;E_OUT, R&gt; terminalOp) {
assert getOutputShape() == terminalOp.inputShape();
if (linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
linkedOrConsumed = true;
return isParallel()
? terminalOp.evaluateParallel(this, sourceSpliterator(terminalOp.getOpFlags()))
: terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOpFlags()));
}
```
这里的并行处理指的是Stream结合了ForkJoin框架对Stream 处理进行了分片Splititerator中的estimateSize方法会估算出分片的数据量。
ForkJoin框架和估算算法在这里我就不具体讲解了如果感兴趣你可以深入源码分析下该算法的实现。
通过预估的数据量获取最小处理单元的阈值如果当前分片大小大于最小处理单元的阈值就继续切分集合。每个分片将会生成一个Sink链表当所有的分片操作完成后ForkJoin框架将会合并分片任何结果集。
## 合理使用Stream
看到这里你应该对Stream API是如何优化集合遍历有个清晰的认知了。Stream API用起来简洁还能并行处理那是不是使用Stream API系统性能就更好呢通过一组测试我们一探究竟。
我们将对常规的迭代、Stream串行迭代以及Stream并行迭代进行性能测试对比迭代循环中我们将对数据进行过滤、分组等操作。分别进行以下几组测试
- 多核CPU服务器配置环境下对比长度100的int数组的性能
- 多核CPU服务器配置环境下对比长度1.00E+8的int数组的性能
- 多核CPU服务器配置环境下对比长度1.00E+8对象数组过滤分组的性能
- 单核CPU服务器配置环境下对比长度1.00E+8对象数组过滤分组的性能。
由于篇幅有限,我这里直接给出统计结果,你也可以自己去验证一下,具体的测试代码可以在[Github](https://github.com/nickliuchao/stream)上查看。通过以上测试,我统计出的测试结果如下(迭代使用时间):
- 常规的迭代&lt;Stream并行迭代&lt;Stream串行迭代
- Stream并行迭代&lt;常规的迭代&lt;Stream串行迭代
- Stream并行迭代&lt;常规的迭代&lt;Stream串行迭代
- 常规的迭代&lt;Stream串行迭代&lt;Stream并行迭代
通过以上测试结果我们可以看到在循环迭代次数较少的情况下常规的迭代方式性能反而更好在单核CPU服务器配置环境中也是常规迭代方式更有优势而在大数据循环迭代中如果服务器是多核CPU的情况下Stream的并行迭代优势明显。所以我们在平时处理大数据的集合时应该尽量考虑将应用部署在多核CPU环境下并且使用Stream的并行迭代方式进行处理。
用事实说话我们看到其实使用Stream未必可以使系统性能更佳还是要结合应用场景进行选择也就是合理地使用Stream。
## 总结
纵观Stream的设计实现非常值得我们学习。从大的设计方向上来说Stream将整个操作分解为了链式结构不仅简化了遍历操作还为实现了并行计算打下了基础。
从小的分类方向上来说Stream将遍历元素的操作和对元素的计算分为中间操作和终结操作而中间操作又根据元素之间状态有无干扰分为有状态和无状态操作实现了链结构中的不同阶段。
**在串行处理操作中,**Stream在执行每一步中间操作时并不会做实际的数据操作处理而是将这些中间操作串联起来最终由终结操作触发生成一个数据处理链表通过Java8中的Spliterator迭代器进行数据处理此时每执行一次迭代就对所有的无状态的中间操作进行数据处理而对有状态的中间操作就需要迭代处理完所有的数据再进行处理操作最后就是进行终结操作的数据处理。
**在并行处理操作中,**Stream对中间操作基本跟串行处理方式是一样的但在终结操作中Stream将结合ForkJoin框架对集合进行切片处理ForkJoin框架将每个切片的处理结果Join合并起来。最后就是要注意Stream的使用场景。
## 思考题
这里有一个简单的并行处理案例,请你找出其中存在的问题。
```
//使用一个容器装载100个数字通过Stream并行处理的方式将容器中为单数的数字转移到容器parallelList
List&lt;Integer&gt; integerList= new ArrayList&lt;Integer&gt;();
for (int i = 0; i &lt;100; i++) {
integerList.add(i);
}
List&lt;Integer&gt; parallelList = new ArrayList&lt;Integer&gt;() ;
integerList.stream()
.parallel()
.filter(i-&gt;i%2==1)
.forEach(i-&gt;parallelList.add(i));
```
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。

View File

@@ -0,0 +1,232 @@
<audio id="audio" title="07 | 深入浅出HashMap的设计与优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/52/bc/52d774c8a6b9659ecb2543e3357a58bc.mp3"></audio>
你好,我是刘超。
在上一讲中我提到过Collection接口那么在Java容器类中除了这个接口之外还定义了一个很重要的Map接口主要用来存储键值对数据。
HashMap作为我们日常使用最频繁的容器之一相信你一定不陌生了。今天我们就从HashMap的底层实现讲起深度了解下它的设计与优化。
## 常用的数据结构
我在05讲分享List集合类的时候讲过ArrayList是基于数组的数据结构实现的LinkedList是基于链表的数据结构实现的而我今天要讲的HashMap是基于哈希表的数据结构实现的。我们不妨一起来温习下常用的数据结构这样也有助于你更好地理解后面地内容。
**数组**采用一段连续的存储单元来存储数据。对于指定下标的查找时间复杂度为O(1),但在数组中间以及头部插入数据时,需要复制移动后面的元素。
**链表**:一种在物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
链表由一系列结点(链表中每一个元素)组成,结点可以在运行时动态生成。每个结点都包含“存储数据单元的数据域”和“存储下一个结点地址的指针域”这两个部分。
由于链表不用必须按顺序存储所以链表在插入的时候可以达到O(1)的复杂度但查找一个结点或者访问特定编号的结点需要O(n)的时间。
**哈希表**根据关键码值Key value直接进行访问的数据结构。通过把关键码值映射到表中一个位置来访问记录以加快查找的速度。这个映射函数叫做哈希函数存放记录的数组就叫做哈希表。
**树**由nn≥1个有限结点组成的一个具有层次关系的集合就像是一棵倒挂的树。
## HashMap的实现结构
了解完数据结构后我们再来看下HashMap的实现结构。作为最常用的Map类它是基于哈希表实现的继承了AbstractMap并且实现了Map接口。
哈希表将键的Hash值映射到内存地址即根据键获取对应的值并将其存储到内存地址。也就是说HashMap是根据键的Hash值来决定对应值的存储位置。通过这种索引方式HashMap获取数据的速度会非常快。
例如存储键值对x“aa”哈希表会通过哈希函数f(x)得到"aa"的实现存储位置。
但也会有新的问题。如果再来一个(y“bb”)哈希函数f(y)的哈希值跟之前f(x)是一样的,这样两个对象的存储地址就冲突了,这种现象就被称为哈希冲突。那么哈希表是怎么解决的呢?方式有很多,比如,开放定址法、再哈希函数法和链地址法。
开放定址法很简单当发生哈希冲突时如果哈希表未被装满说明在哈希表中必然还有空位置那么可以把key存放到冲突位置后面的空位置上去。这种方法存在着很多缺点例如查找、扩容等所以我不建议你作为解决哈希冲突的首选。
再哈希法顾名思义就是在同义词产生地址冲突时再计算另一个哈希函数地址,直到冲突不再发生,这种方法不易产生“聚集”,但却增加了计算时间。如果我们不考虑添加元素的时间成本,且对查询元素的要求极高,就可以考虑使用这种算法设计。
HashMap则是综合考虑了所有因素采用链地址法解决哈希冲突问题。这种方法是采用了数组哈希表+ 链表的数据结构当发生哈希冲突时就用一个链表结构存储相同Hash值的数据。
## HashMap的重要属性
从HashMap的源码中我们可以发现HashMap是由一个Node数组构成每个Node包含了一个key-value键值对。
```
transient Node&lt;K,V&gt;[] table;
```
Node类作为HashMap中的一个内部类除了key、value两个属性外还定义了一个next指针。当有哈希冲突时HashMap会用之前数组当中相同哈希值对应存储的Node对象通过指针指向新增的相同哈希值的Node对象的引用。
```
static class Node&lt;K,V&gt; implements Map.Entry&lt;K,V&gt; {
final int hash;
final K key;
V value;
Node&lt;K,V&gt; next;
Node(int hash, K key, V value, Node&lt;K,V&gt; next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
```
HashMap还有两个重要的属性加载因子loadFactor和边界值threshold。在初始化HashMap时就会涉及到这两个关键初始化参数。
```
int threshold;
final float loadFactor;
```
LoadFactor属性是用来间接设置Entry数组哈希表的内存空间大小在初始HashMap不设置参数的情况下默认LoadFactor值为0.75。**为什么是0.75这个值呢?**
这是因为对于使用链表法的哈希表来说查找一个元素的平均时间是O(1+n)这里的n指的是遍历链表的长度因此加载因子越大对空间的利用就越充分这就意味着链表的长度越长查找效率也就越低。如果设置的加载因子太小那么哈希表的数据将过于稀疏对空间造成严重浪费。
那有没有什么办法来解决这个因链表过长而导致的查询时间复杂度高的问题呢?你可以先想想,我将在后面的内容中讲到。
Entry数组的Threshold是通过初始容量和LoadFactor计算所得在初始HashMap不设置参数的情况下默认边界值为12。如果我们在初始化时设置的初始化容量较小HashMap中Node的数量超过边界值HashMap就会调用resize()方法重新分配table数组。这将会导致HashMap的数组复制迁移到另一块内存中去从而影响HashMap的效率。
## HashMap添加元素优化
初始化完成后HashMap就可以使用put()方法添加键值对了。从下面源码可以看出当程序将一个key-value对添加到HashMap中程序首先会根据该key的hashCode()返回值再通过hash()方法计算出hash值再通过putVal方法中的(n - 1) &amp; hash决定该Node的存储位置。
```
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
```
```
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h &gt;&gt;&gt; 16);
}
```
```
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通过putVal方法中的(n - 1) &amp; hash决定该Node的存储位置
if ((p = tab[i = (n - 1) &amp; hash]) == null)
tab[i] = newNode(hash, key, value, null);
```
如果你不太清楚hash()以及(n-1)&amp;hash的算法就请你看下面的详述。
我们先来了解下hash()方法中的算法。如果我们没有使用hash()方法计算hashCode而是直接使用对象的hashCode值会出现什么问题呢
假设要添加两个对象a和b如果数组长度是16这时对象a和b通过公式(n - 1) &amp; hash运算也就是(16-1)a.hashCode和(16-1)b.hashCode15的二进制为0000000000000000000000000001111假设对象 A 的 hashCode 为 1000010001110001000001111000000对象 B 的 hashCode 为 0111011100111000101000010100000你会发现上述与运算结果都是0。这样的哈希结果就太让人失望了很明显不是一个好的哈希算法。
但如果我们将 hashCode 值右移 16 位h &gt;&gt;&gt; 16代表无符号右移16位也就是取 int 类型的一半刚好可以将该二进制数对半切开并且使用位异或运算如果两个数对应的位置相反则结果为1反之为0这样的话就能避免上面的情况发生。这就是hash()方法的具体实现方式。**简而言之就是尽量打乱hashCode真正参与运算的低16位。**
我再来解释下(n - 1) &amp; hash是怎么设计的这里的n代表哈希表的长度哈希表习惯将长度设置为2的n次方这样恰好可以保证(n - 1) &amp; hash的计算得到的索引值总是位于table数组的索引之内。例如hash=15n=16时结果为15hash=17n=16时结果为1。
在获得Node的存储位置后如果判断Node不在哈希表中就新增一个Node并添加到哈希表中整个流程我将用一张图来说明
<img src="https://static001.geekbang.org/resource/image/eb/d9/ebc8c027e556331dc327e18feb00c7d9.jpg" alt="">
**从图中我们可以看出:**在JDK1.8中HashMap引入了红黑树数据结构来提升链表的查询效率。
这是因为链表的长度超过8后红黑树的查询效率要比链表高所以当链表超过8时HashMap就会将链表转换为红黑树这里值得注意的一点是这时的新增由于存在左旋、右旋效率会降低。讲到这里我前面我提到的“因链表过长而导致的查询时间复杂度高”的问题也就迎刃而解了。
以下就是put的实现源码:
```
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node&lt;K,V&gt;[] tab; Node&lt;K,V&gt; p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//1、判断当table为null或者tab的长度为0时即table尚未初始化此时通过resize()方法得到初始化的table
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) &amp; hash]) == null)
//1.1、此处通过n - 1 &amp; hash 计算出的值作为tab的下标i并另p表示tab[i]也就是该链表第一个节点的位置。并判断p是否为null
tab[i] = newNode(hash, key, value, null);
//1.1.1、当p为null时表明tab[i]上没有任何元素那么接下来就new第一个Node节点调用newNode方法返回新节点赋值给tab[i]
else {
//2.1下面进入p不为null的情况有三种情况p为链表节点p为红黑树节点p是链表节点但长度为临界长度TREEIFY_THRESHOLD再插入任何元素就要变成红黑树了。
Node&lt;K,V&gt; e; K k;
if (p.hash == hash &amp;&amp;
((k = p.key) == key || (key != null &amp;&amp; key.equals(k))))
//2.1.1HashMap中判断key相同的条件是key的hash相同并且符合equals方法。这里判断了p.key是否和插入的key相等如果相等则将p的引用赋给e
e = p;
else if (p instanceof TreeNode)
//2.1.2现在开始了第一种情况p是红黑树节点那么肯定插入后仍然是红黑树节点所以我们直接强制转型p后调用TreeNode.putTreeVal方法返回的引用赋给e
e = ((TreeNode&lt;K,V&gt;)p).putTreeVal(this, tab, hash, key, value);
else {
//2.1.3接下里就是p为链表节点的情形也就是上述说的另外两类情况插入后还是链表/插入后转红黑树。另外上行转型代码也说明了TreeNode是Node的一个子类
for (int binCount = 0; ; ++binCount) {
//我们需要一个计数器来计算当前链表的元素个数并遍历链表binCount就是这个计数器
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount &gt;= TREEIFY_THRESHOLD - 1)
// 插入成功后要判断是否需要转换为红黑树因为插入后链表长度加1而binCount并不包含新节点所以判断时要将临界阈值减1
treeifyBin(tab, hash);
//当新长度满足转换条件时调用treeifyBin方法将该链表转换为红黑树
break;
}
if (e.hash == hash &amp;&amp;
((k = e.key) == key || (key != null &amp;&amp; key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size &gt; threshold)
resize();
afterNodeInsertion(evict);
return null;
}
```
## HashMap获取元素优化
当HashMap中只存在数组而数组中没有Node链表时是HashMap查询数据性能最好的时候。一旦发生大量的哈希冲突就会产生Node链表这个时候每次查询元素都可能遍历Node链表从而降低查询数据的性能。
特别是在链表长度过长的情况下性能将明显降低红黑树的使用很好地解决了这个问题使得查询的平均复杂度降低到了O(log(n)),链表越长,使用黑红树替换后的查询效率提升就越明显。
我们在编码中也可以优化HashMap的性能例如重写key值的hashCode()方法,降低哈希冲突,从而减少链表的产生,高效利用哈希表,达到提高性能的效果。
## HashMap扩容优化
HashMap也是数组类型的数据结构所以一样存在扩容的情况。
在JDK1.7 中HashMap整个扩容过程就是分别取出数组元素一般该元素是最后一个放入链表中的元素然后遍历以该元素为头的单向链表元素依据每个被遍历元素的 hash 值计算其在新数组中的下标,然后进行交换。这样的扩容方式会将原来哈希冲突的单向链表尾部变成扩容后单向链表的头部。
而在 JDK 1.8 中HashMap对扩容操作做了优化。由于扩容数组的长度是 2 倍关系,所以对于假设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化(左移一位就是 2 倍),在扩容中只用判断原来的 hash 值和左移动的一位newtable 的值)按位与操作是 0 或 1 就行0 的话索引不变1 的话索引变成原索引加上扩容前数组。
之所以能通过这种“与运算“来重新分配索引,是因为 hash 值本来就是随机的而hash 按位与上 newTable 得到的 0扩容前的索引位置和 1扩容前索引位置加上扩容前数组长度的数值索引处就是随机的所以扩容的过程就能把之前哈希冲突的元素再随机分布到不同的索引中去。
## 总结
HashMap通过哈希表数据结构的形式来存储键值对这种设计的好处就是查询键值对的效率高。
我们在使用HashMap时可以结合自己的场景来设置初始容量和加载因子两个参数。当查询操作较为频繁时我们可以适当地减少加载因子如果对内存利用率要求比较高我可以适当的增加加载因子。
我们还可以在预知存储数据量的情况下,提前设置初始容量(初始容量=预知数据量/加载因子。这样做的好处是可以减少resize()操作提高HashMap的效率。
HashMap还使用了数组+链表这两种数据结构相结合的方式实现了链地址法,当有哈希值冲突时,就可以将冲突的键值对链成一个链表。
但这种方式又存在一个性能问题如果链表过长查询数据的时间复杂度就会增加。HashMap就在Java8中使用了红黑树来解决链表过长导致的查询性能下降问题。以下是HashMap的数据结构图
<img src="https://static001.geekbang.org/resource/image/c0/6f/c0a12608e37753c96f2358fe0f6ff86f.jpg" alt="">
## 思考题
实际应用中我们设置初始容量一般得是2的整数次幂。你知道原因吗
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。

View File

@@ -0,0 +1,163 @@
<audio id="audio" title="08 | 网络通信优化之I/O模型如何解决高并发下I/O瓶颈" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/66/81/66c16346051ff3ecff4164e3777e4981.mp3"></audio>
你好,我是刘超。
提到Java I/O相信你一定不陌生。你可能使用I/O操作读写文件也可能使用它实现Socket的信息传输…这些都是我们在系统中最常遇到的和I/O有关的操作。
我们都知道I/O的速度要比内存速度慢尤其是在现在这个大数据时代背景下I/O的性能问题更是尤为突出I/O读写已经成为很多应用场景下的系统性能瓶颈不容我们忽视。
今天我们就来深入了解下Java I/O在高并发、大数据业务场景下暴露出的性能问题从源头入手学习优化方法。
## 什么是I/O
I/O是机器获取和交换信息的主要渠道而流是完成I/O操作的主要方式。
在计算机中流是一种信息的转换。流是有序的因此相对于某一机器或者应用程序而言我们通常把机器或者应用程序接收外界的信息称为输入流InputStream从机器或者应用程序向外输出的信息称为输出流OutputStream合称为输入/输出流I/O Streams
机器间或程序间在进行信息交换或者数据交换时,总是先将对象或数据转换为某种形式的流,再通过流的传输,到达指定机器或程序后,再将流转换为对象数据。因此,流就可以被看作是一种数据的载体,通过它可以实现数据交换和传输。
Java的I/O操作类在包java.io下其中InputStream、OutputStream以及Reader、Writer类是I/O包中的4个基本类它们分别处理字节流和字符流。如下图所示
<img src="https://static001.geekbang.org/resource/image/64/7d/64f1e83054e2997f1fd96b221fb0da7d.jpg" alt="">
回顾我的经历我记得在初次阅读Java I/O流文档的时候我有过这样一个疑问在这里也分享给你那就是“**不管是文件读写还是网络发送接收信息的最小存储单元都是字节那为什么I/O流操作要分为字节流操作和字符流操作呢**”
我们知道字符到字节必须经过转码这个过程非常耗时如果我们不知道编码类型就很容易出现乱码问题。所以I/O流提供了一个直接操作字符的接口方便我们平时对字符进行流操作。下面我们就分别了解下“字节流”和“字符流”。
### 1.字节流
InputStream/OutputStream是字节流的抽象类这两个抽象类又派生出了若干子类不同的子类分别处理不同的操作类型。如果是文件的读写操作就使用FileInputStream/FileOutputStream如果是数组的读写操作就使用ByteArrayInputStream/ByteArrayOutputStream如果是普通字符串的读写操作就使用BufferedInputStream/BufferedOutputStream。具体内容如下图所示
<img src="https://static001.geekbang.org/resource/image/12/8f/12bbf6e62c7c29ae82bf90fead72b98f.jpg" alt="">
### 2.字符流
Reader/Writer是字符流的抽象类这两个抽象类也派生出了若干子类不同的子类分别处理不同的操作类型具体内容如下图所示
<img src="https://static001.geekbang.org/resource/image/24/9f/24592c6f90300f7bab86ec4141dd7e9f.jpg" alt="">
## 传统I/O的性能问题
我们知道I/O操作分为磁盘I/O操作和网络I/O操作。前者是从磁盘中读取数据源输入到内存中之后将读取的信息持久化输出在物理磁盘上后者是从网络中读取信息输入到内存最终将信息输出到网络中。但不管是磁盘I/O还是网络I/O在传统I/O中都存在严重的性能问题。
### 1.多次内存复制
在传统I/O中我们可以通过InputStream从源数据中读取数据流输入到缓冲区里通过OutputStream将数据输出到外部设备包括磁盘、网络。你可以先看下输入操作在操作系统中的具体流程如下图所示
<img src="https://static001.geekbang.org/resource/image/4c/c2/4c4af15b08d3b11de3fe603a70dc6ac2.jpg" alt="">
- JVM会发出read()系统调用并通过read系统调用向内核发起读请求
- 内核向硬件发送读指令,并等待读就绪;
- 内核把将要读取的数据复制到指向的内核缓存中;
- 操作系统内核将数据复制到用户空间缓冲区然后read系统调用返回。
在这个过程中数据先从外部设备复制到内核空间再从内核空间复制到用户空间这就发生了两次内存复制操作。这种操作会导致不必要的数据拷贝和上下文切换从而降低I/O的性能。
### 2.阻塞
在传统I/O中InputStream的read()是一个while循环操作它会一直等待数据读取直到数据就绪才会返回。**这就意味着如果没有数据就绪,这个读取操作将会一直被挂起,用户线程将会处于阻塞状态。**
在少量连接请求的情况下使用这种方式没有问题响应速度也很高。但在发生大量连接请求时就需要创建大量监听线程这时如果线程没有数据就绪就会被挂起然后进入阻塞状态。一旦发生线程阻塞这些线程将会不断地抢夺CPU资源从而导致大量的CPU上下文切换增加系统的性能开销。
## 如何优化I/O操作
面对以上两个性能问题不仅编程语言对此做了优化各个操作系统也进一步优化了I/O。JDK1.4发布了java.nio包new I/O的缩写NIO的发布优化了内存复制以及阻塞导致的严重性能问题。JDK1.7又发布了NIO2提出了从操作系统层面实现的异步I/O。下面我们就来了解下具体的优化实现。
### 1.使用缓冲区优化读写流操作
在传统I/O中提供了基于流的I/O实现即InputStream和OutputStream这种基于流的实现以字节为单位处理数据。
NIO与传统 I/O 不同它是基于块Block它以块为基本单位处理数据。在NIO中最为重要的两个组件是缓冲区Buffer和通道Channel。Buffer是一块连续的内存块是 NIO 读写数据的中转地。Channel表示缓冲数据的源头或者目的地它用于读取缓冲或者写入数据是访问缓冲的接口。
传统I/O和NIO的最大区别就是传统I/O是面向流NIO是面向Buffer。Buffer可以将文件一次性读入内存再做后续处理而传统的方式是边读文件边处理数据。虽然传统I/O后面也使用了缓冲块例如BufferedInputStream但仍然不能和NIO相媲美。使用NIO替代传统I/O操作可以提升系统的整体性能效果立竿见影。
### 2. 使用DirectBuffer减少内存复制
NIO的Buffer除了做了缓冲块优化之外还提供了一个可以直接访问物理内存的类DirectBuffer。普通的Buffer分配的是JVM堆内存而DirectBuffer是直接分配物理内存(非堆内存)。
我们知道数据要输出到外部设备必须先从用户空间复制到内核空间再复制到输出设备而在Java中在用户空间中又存在一个拷贝那就是从Java堆内存中拷贝到临时的直接内存中通过临时的直接内存拷贝到内存空间中去。此时的直接内存和堆内存都是属于用户空间。
<img src="https://static001.geekbang.org/resource/image/39/c2/399d715ed2f687e22ec9ca2a65bd88c2.jpg" alt="">
你肯定会在想为什么Java需要通过一个临时的非堆内存来复制数据呢如果单纯使用Java堆内存进行数据拷贝当拷贝的数据量比较大的情况下Java堆的GC压力会比较大而使用非堆内存可以减低GC的压力。
DirectBuffer则是直接将步骤简化为数据直接保存到非堆内存从而减少了一次数据拷贝。以下是JDK源码中IOUtil.java类中的write方法
```
if (src instanceof DirectBuffer)
return writeFromNativeBuffer(fd, src, position, nd);
// Substitute a native buffer
int pos = src.position();
int lim = src.limit();
assert (pos &lt;= lim);
int rem = (pos &lt;= lim ? lim - pos : 0);
ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
try {
bb.put(src);
bb.flip();
// ...............
```
这里拓展一点由于DirectBuffer申请的是非JVM的物理内存所以创建和销毁的代价很高。DirectBuffer申请的内存并不是直接由JVM负责垃圾回收但在DirectBuffer包装类被回收时会通过Java Reference机制来释放该内存块。
DirectBuffer只优化了用户空间内部的拷贝而之前我们是说优化用户空间和内核空间的拷贝那Java的NIO中是否能做到减少用户空间和内核空间的拷贝优化呢
答案是可以的DirectBuffer是通过unsafe.allocateMemory(size)方法分配内存也就是基于本地类Unsafe类调用native方法进行内存分配的。而在NIO中还存在另外一个Buffer类MappedByteBuffer跟DirectBuffer不同的是MappedByteBuffer是通过本地类调用mmap进行文件内存映射的map()系统调用方法会直接将文件从硬盘拷贝到用户空间只进行一次数据拷贝从而减少了传统的read()方法从硬盘拷贝到内核空间这一步。
### 3.避免阻塞优化I/O操作
NIO很多人也称之为Non-block I/O即非阻塞I/O因为这样叫更能体现它的特点。为什么这么说呢
传统的I/O即使使用了缓冲块依然存在阻塞问题。由于线程池线程数量有限一旦发生大量并发请求超过最大数量的线程就只能等待直到线程池中有空闲的线程可以被复用。而对Socket的输入流进行读取时读取流会一直阻塞直到发生以下三种情况的任意一种才会解除阻塞
- 有数据可读;
- 连接释放;
- 空指针或I/O异常。
阻塞问题就是传统I/O最大的弊端。NIO发布后通道和多路复用器这两个基本组件实现了NIO的非阻塞下面我们就一起来了解下这两个组件的优化原理。
**通道Channel**
前面我们讨论过传统I/O的数据读取和写入是从用户空间到内核空间来回复制而内核空间的数据是通过操作系统层面的I/O接口从磁盘读取或写入。
最开始在应用程序调用操作系统I/O接口时是由CPU完成分配这种方式最大的问题是“发生大量I/O请求时非常消耗CPU“之后操作系统引入了DMA直接存储器存储内核空间与磁盘之间的存取完全由DMA负责但这种方式依然需要向CPU申请权限且需要借助DMA总线来完成数据的复制操作如果DMA总线过多就会造成总线冲突。
通道的出现解决了以上问题Channel有自己的处理器可以完成内核空间和磁盘之间的I/O操作。在NIO中我们读取和写入数据都要通过Channel由于Channel是双向的所以读、写可以同时进行。
**多路复用器Selector**
Selector是Java NIO编程的基础。用于检查一个或多个NIO Channel的状态是否处于可读、可写。
Selector是基于事件驱动实现的我们可以在Selector中注册accpet、read监听事件Selector会不断轮询注册在其上的Channel如果某个Channel上面发生监听事件这个Channel就处于就绪状态然后进行I/O操作。
一个线程使用一个Selector通过轮询的方式可以监听多个Channel上的事件。我们可以在注册Channel时设置该通道为非阻塞当Channel上没有I/O操作时该线程就不会一直等待了而是会不断轮询所有Channel从而避免发生阻塞。
目前操作系统的I/O多路复用机制都使用了epoll相比传统的select机制epoll没有最大连接句柄1024的限制。所以Selector在理论上可以轮询成千上万的客户端。
**下面我用一个生活化的场景来举例,**看完你就更清楚Channel和Selector在非阻塞I/O中承担什么角色发挥什么作用了。
我们可以把监听多个I/O连接请求比作一个火车站的进站口。以前检票只能让搭乘就近一趟发车的旅客提前进站而且只有一个检票员这时如果有其他车次的旅客要进站就只能在站口排队。这就相当于最早没有实现线程池的I/O操作。
后来火车站升级了多了几个检票入口允许不同车次的旅客从各自对应的检票入口进站。这就相当于用多线程创建了多个监听线程同时监听各个客户端的I/O请求。
最后火车站进行了升级改造可以容纳更多旅客了每个车次载客更多了而且车次也安排合理乘客不再扎堆排队可以从一个大的统一的检票口进站了这一个检票口可以同时检票多个车次。这个大的检票口就相当于Selector车次就相当于Channel旅客就相当于I/O流。
## 总结
Java的传统I/O开始是基于InputStream和OutputStream两个操作流实现的这种流操作是以字节为单位如果在高并发、大数据场景中很容易导致阻塞因此这种操作的性能是非常差的。还有输出数据从用户空间复制到内核空间再复制到输出设备这样的操作会增加系统的性能开销。
传统I/O后来使用了Buffer优化了“阻塞”这个性能问题以缓冲块作为最小单位但相比整体性能来说依然不尽人意。
于是NIO发布它是基于缓冲块为单位的流操作在Buffer的基础上新增了两个组件“管道和多路复用器”实现了非阻塞I/ONIO适用于发生大量I/O连接请求的场景这三个组件共同提升了I/O的整体性能。
你可以在[Github](https://github.com/nickliuchao/io)上通过几个简单的例子来实践下传统IO、NIO。
## 思考题
在JDK1.7版本中Java发布了NIO的升级包NIO2也就是AIO。AIO实现了真正意义上的异步I/O它是直接将I/O操作交给操作系统进行异步处理。这也是对I/O操作的一种优化那为什么现在很多容器的通信框架都还是使用NIO呢
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。

View File

@@ -0,0 +1,256 @@
<audio id="audio" title="09 | 网络通信优化之序列化避免使用Java序列化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a5/ca/a5a59e3de030f425f1c2a019b4ddedca.mp3"></audio>
你好,我是刘超。
当前大部分后端服务都是基于微服务架构实现的。服务按照业务划分被拆分,实现了服务的解耦,但同时也带来了新的问题,不同业务之间通信需要通过接口实现调用。两个服务之间要共享一个数据对象,就需要从对象转换成二进制流,通过网络传输,传送到对方服务,再转换回对象,供服务方法调用。**这个编码和解码过程我们称之为序列化与反序列化。**
在大量并发请求的情况下,如果序列化的速度慢,会导致请求响应时间增加;而序列化后的传输数据体积大,会导致网络吞吐量下降。所以一个优秀的序列化框架可以提高系统的整体性能。
我们知道Java提供了RMI框架可以实现服务与服务之间的接口暴露和调用RMI中对数据对象的序列化采用的是Java序列化。而目前主流的微服务框架却几乎没有用到Java序列化SpringCloud用的是Json序列化Dubbo虽然兼容了Java序列化但默认使用的是Hessian序列化。这是为什么呢
今天我们就来深入了解下Java序列化再对比近两年比较火的Protobuf序列化看看Protobuf是如何实现最优序列化的。
## Java序列化
在说缺陷之前你先得知道什么是Java序列化以及它的实现原理。
Java提供了一种序列化机制这种机制能够将一个对象序列化为二进制形式字节数组用于写入磁盘或输出到网络同时也能从网络或磁盘中读取字节数组反序列化成对象在程序中使用。
<img src="https://static001.geekbang.org/resource/image/bd/e2/bd4bc4b2746f4b005ca26042412f4ee2.png" alt="">
JDK提供的两个输入、输出流对象ObjectInputStream和ObjectOutputStream它们只能对实现了Serializable接口的类的对象进行反序列化和序列化。
ObjectOutputStream的默认序列化方式仅对对象的非transient的实例变量进行序列化而不会序列化对象的transient的实例变量也不会序列化静态变量。
在实现了Serializable接口的类的对象中会生成一个serialVersionUID的版本号这个版本号有什么用呢它会在反序列化过程中来验证序列化对象是否加载了反序列化的类如果是具有相同类名的不同版本号的类在反序列化中是无法获取对象的。
具体实现序列化的是writeObject和readObject通常这两个方法是默认的当然我们也可以在实现Serializable接口的类中对其进行重写定制一套属于自己的序列化与反序列化机制。
另外Java序列化的类中还定义了两个重写方法writeReplace()和readResolve(),前者是用来在序列化之前替换序列化对象的,后者是用来在反序列化之后对返回对象进行处理的。
## Java序列化的缺陷
如果你用过一些RPC通信框架你就会发现这些框架很少使用JDK提供的序列化。其实不用和不好用多半是挂钩的下面我们就一起来看看JDK默认的序列化到底存在着哪些缺陷。
### 1.无法跨语言
现在的系统设计越来越多元化很多系统都使用了多种语言来编写应用程序。比如我们公司开发的一些大型游戏就使用了多种语言C++写游戏服务Java/Go写周边服务Python写一些监控应用。
而Java序列化目前只适用基于Java语言实现的框架其它语言大部分都没有使用Java的序列化框架也没有实现Java序列化这套协议。因此如果是两个基于不同语言编写的应用程序相互通信则无法实现两个应用服务之间传输对象的序列化与反序列化。
### 2.易被攻击
Java官网安全编码指导方针中说明“对不信任数据的反序列化从本质上来说是危险的应该予以避免”。可见Java序列化是不安全的。
我们知道对象是通过在ObjectInputStream上调用readObject()方法进行反序列化的这个方法其实是一个神奇的构造器它可以将类路径上几乎所有实现了Serializable接口的对象都实例化。
这也就意味着,在反序列化字节流的过程中,该方法可以执行任意类型的代码,这是非常危险的。
对于需要长时间进行反序列化的对象不需要执行任何代码也可以发起一次攻击。攻击者可以创建循环对象链然后将序列化后的对象传输到程序中反序列化这种情况会导致hashCode方法被调用次数呈次方爆发式增长, 从而引发栈溢出异常。例如下面这个案例就可以很好地说明。
```
Set root = new HashSet();
Set s1 = root;
Set s2 = new HashSet();
for (int i = 0; i &lt; 100; i++) {
Set t1 = new HashSet();
Set t2 = new HashSet();
t1.add(&quot;foo&quot;); //使t2不等于t1
s1.add(t1);
s1.add(t2);
s2.add(t1);
s2.add(t2);
s1 = t1;
s2 = t2;
}
```
2015年FoxGlove Security安全团队的breenmachine发布过一篇长博客主要内容是通过Apache Commons CollectionsJava反序列化漏洞可以实现攻击。一度横扫了WebLogic、WebSphere、JBoss、Jenkins、OpenNMS的最新版各大Java Web Server纷纷躺枪。
其实Apache Commons Collections就是一个第三方基础库它扩展了Java标准库里的Collection结构提供了很多强有力的数据结构类型并且实现了各种集合工具类。
实现攻击的原理就是Apache Commons Collections允许链式的任意的类函数反射调用攻击者通过“实现了Java序列化协议”的端口把攻击代码上传到服务器上再由Apache Commons Collections里的TransformedMap来执行。
**那么后来是如何解决这个漏洞的呢?**
很多序列化协议都制定了一套数据结构来保存和获取对象。例如JSON序列化、ProtocolBuf等它们只支持一些基本类型和数组数据类型这样可以避免反序列化创建一些不确定的实例。虽然它们的设计简单但足以满足当前大部分系统的数据传输需求。
我们也可以通过反序列化对象白名单来控制反序列化对象可以重写resolveClass方法并在该方法中校验对象名字。代码如下所示
```
@Override
protected Class resolveClass(ObjectStreamClass desc) throws IOException,ClassNotFoundException {
if (!desc.getName().equals(Bicycle.class.getName())) {
throw new InvalidClassException(
&quot;Unauthorized deserialization attempt&quot;, desc.getName());
}
return super.resolveClass(desc);
}
```
### 3.序列化后的流太大
序列化后的二进制流大小能体现序列化的性能。序列化后的二进制数组越大,占用的存储空间就越多,存储硬件的成本就越高。如果我们是进行网络传输,则占用的带宽就更多,这时就会影响到系统的吞吐量。
Java序列化中使用了ObjectOutputStream来实现对象转二进制编码那么这种序列化机制实现的二进制编码完成的二进制数组大小相比于NIO中的ByteBuffer实现的二进制编码完成的数组大小有没有区别呢
我们可以通过一个简单的例子来验证下:
```
User user = new User();
user.setUserName(&quot;test&quot;);
user.setPassword(&quot;test&quot;);
ByteArrayOutputStream os =new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(os);
out.writeObject(user);
byte[] testByte = os.toByteArray();
System.out.print(&quot;ObjectOutputStream 字节编码长度:&quot; + testByte.length + &quot;\n&quot;);
```
```
ByteBuffer byteBuffer = ByteBuffer.allocate( 2048);
byte[] userName = user.getUserName().getBytes();
byte[] password = user.getPassword().getBytes();
byteBuffer.putInt(userName.length);
byteBuffer.put(userName);
byteBuffer.putInt(password.length);
byteBuffer.put(password);
byteBuffer.flip();
byte[] bytes = new byte[byteBuffer.remaining()];
System.out.print(&quot;ByteBuffer 字节编码长度:&quot; + bytes.length+ &quot;\n&quot;);
```
运行结果:
```
ObjectOutputStream 字节编码长度99
ByteBuffer 字节编码长度16
```
这里我们可以清楚地看到Java序列化实现的二进制编码完成的二进制数组大小比ByteBuffer实现的二进制编码完成的二进制数组大小要大上几倍。因此Java序列后的流会变大最终会影响到系统的吞吐量。
### 4.序列化性能太差
序列化的速度也是体现序列化性能的重要指标如果序列化的速度慢就会影响网络通信的效率从而增加系统的响应时间。我们再来通过上面这个例子来对比下Java序列化与NIO中的ByteBuffer编码的性能
```
User user = new User();
user.setUserName(&quot;test&quot;);
user.setPassword(&quot;test&quot;);
long startTime = System.currentTimeMillis();
for(int i=0; i&lt;1000; i++) {
ByteArrayOutputStream os =new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(os);
out.writeObject(user);
out.flush();
out.close();
byte[] testByte = os.toByteArray();
os.close();
}
long endTime = System.currentTimeMillis();
System.out.print(&quot;ObjectOutputStream 序列化时间:&quot; + (endTime - startTime) + &quot;\n&quot;);
```
```
long startTime1 = System.currentTimeMillis();
for(int i=0; i&lt;1000; i++) {
ByteBuffer byteBuffer = ByteBuffer.allocate( 2048);
byte[] userName = user.getUserName().getBytes();
byte[] password = user.getPassword().getBytes();
byteBuffer.putInt(userName.length);
byteBuffer.put(userName);
byteBuffer.putInt(password.length);
byteBuffer.put(password);
byteBuffer.flip();
byte[] bytes = new byte[byteBuffer.remaining()];
}
long endTime1 = System.currentTimeMillis();
System.out.print(&quot;ByteBuffer 序列化时间:&quot; + (endTime1 - startTime1)+ &quot;\n&quot;);
```
运行结果:
```
ObjectOutputStream 序列化时间29
ByteBuffer 序列化时间6
```
通过以上案例我们可以清楚地看到Java序列化中的编码耗时要比ByteBuffer长很多。
## 使用Protobuf序列化替换Java序列化
目前业内优秀的序列化框架有很多而且大部分都避免了Java默认序列化的一些缺陷。例如最近几年比较流行的FastJson、Kryo、Protobuf、Hessian等。**我们完全可以找一种替换掉Java序列化这里我推荐使用Protobuf序列化框架。**
Protobuf是由Google推出且支持多语言的序列化框架目前在主流网站上的序列化框架性能对比测试报告中Protobuf无论是编解码耗时还是二进制流压缩大小都名列前茅。
Protobuf以一个 .proto 后缀的文件为基础这个文件描述了字段以及字段类型通过工具可以生成不同语言的数据结构文件。在序列化该数据对象的时候Protobuf通过.proto文件描述来生成Protocol Buffers格式的编码。
**这里拓展一点我来讲下什么是Protocol Buffers存储格式以及它的实现原理。**
Protocol Buffers 是一种轻便高效的结构化数据存储格式。它使用T-L-V标识 - 长度 - 字段值的数据格式来存储数据T代表字段的正数序列(tag)Protocol Buffers 将对象中的每个字段和正数序列对应起来对应关系的信息是由生成的代码来保证的。在序列化的时候用整数值来代替字段名称于是传输流量就可以大幅缩减L代表Value的字节长度一般也只占一个字节V则代表字段值经过编码后的值。这种数据格式不需要分隔符也不需要空格同时减少了冗余字段名。
Protobuf定义了一套自己的编码方式几乎可以映射Java/Python等语言的所有基础数据类型。不同的编码方式对应不同的数据类型还能采用不同的存储格式。如下图所示
<img src="https://static001.geekbang.org/resource/image/ec/eb/ec0ebe4f622e9edcd9de86cb92f15eeb.jpg" alt="">
对于存储Varint编码数据由于数据占用的存储空间是固定的就不需要存储字节长度 Length所以实际上Protocol Buffers的存储方式是 T - V这样就又减少了一个字节的存储空间。
Protobuf定义的Varint编码方式是一种变长的编码方式每个字节的最后一位(即最高位)是一个标志位(msb)用0和1来表示0表示当前字节已经是最后一个字节1表示这个数字后面还有一个字节。
对于int32类型数字一般需要4个字节表示若采用Varint编码方式对于很小的int32类型数字就可以用1个字节来表示。对于大部分整数类型数据来说一般都是小于256所以这种操作可以起到很好地压缩数据的效果。
我们知道int32代表正负数所以一般最后一位是用来表示正负值现在Varint编码方式将最后一位用作了标志位那还如何去表示正负整数呢如果使用int32/int64表示负数就需要多个字节来表示在Varint编码类型中通过Zigzag编码进行转换将负数转换成无符号数再采用sint32/sint64来表示负数这样就可以大大地减少编码后的字节数。
Protobuf的这种数据存储格式不仅压缩存储数据的效果好 在编码和解码的性能方面也很高效。Protobuf的编码和解码过程结合.proto文件格式加上Protocol Buffer独特的编码格式只需要简单的数据运算以及位移等操作就可以完成编码与解码。可以说Protobuf的整体性能非常优秀。
## 总结
无论是网路传输还是磁盘持久化数据,我们都需要将数据编码成字节码,而我们平时在程序中使用的数据都是基于内存的数据类型或者对象,我们需要通过编码将这些数据转化成二进制字节流;如果需要接收或者再使用时,又需要通过解码将二进制字节流转换成内存数据。我们通常将这两个过程称为序列化与反序列化。
Java默认的序列化是通过Serializable接口实现的只要类实现了该接口同时生成一个默认的版本号我们无需手动设置该类就会自动实现序列化与反序列化。
Java默认的序列化虽然实现方便但却存在安全漏洞、不跨语言以及性能差等缺陷所以我强烈建议你避免使用Java序列化。
纵观主流序列化框架FastJson、Protobuf、Kryo是比较有特点的而且性能以及安全方面都得到了业界的认可我们可以结合自身业务来选择一种适合的序列化框架来优化系统的序列化性能。
## 思考题
这是一个使用单例模式实现的类如果我们将该类实现Java的Serializable接口它还是单例吗如果要你来写一个实现了Java的Serializable接口的单例你会怎么写呢
```
public class Singleton implements Serializable{
private final static Singleton singleInstance = new Singleton();
private Singleton(){}
public static Singleton getInstance(){
return singleInstance;
}
}
```
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。

View File

@@ -0,0 +1,168 @@
<audio id="audio" title="10 | 网络通信优化之通信协议如何优化RPC网络通信" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5b/00/5b68aa25cbd602da866668e9272e6b00.mp3"></audio>
你好,我是刘超。今天我将带你了解下服务间的网络通信优化。
上一讲中我提到了微服务框架其中SpringCloud和Dubbo的使用最为广泛行业内也一直存在着对两者的比较很多技术人会为这两个框架哪个更好而争辩。
我记得我们部门在搭建微服务框架时也在技术选型上纠结良久还曾一度有过激烈的讨论。当前SpringCloud炙手可热具备完整的微服务生态得到了很多同事的票选但我们最终的选择却是Dubbo这是为什么呢
## RPC通信是大型服务框架的核心
我们经常讨论微服务,首要应该了解的就是微服务的核心到底是什么,这样我们在做技术选型时,才能更准确地把握需求。
就我个人理解,我认为微服务的核心是远程通信和服务治理。远程通信提供了服务之间通信的桥梁,服务治理则提供了服务的后勤保障。所以,我们在做技术选型时,更多要考虑的是这两个核心的需求。
我们知道服务的拆分增加了通信的成本,特别是在一些抢购或者促销的业务场景中,如果服务之间存在方法调用,比如,抢购成功之后需要调用订单系统、支付系统、券包系统等,这种远程通信就很容易成为系统的瓶颈。所以,在满足一定的服务治理需求的前提下,对远程通信的性能需求就是技术选型的主要影响因素。
目前很多微服务框架中的服务通信是基于RPC通信实现的在没有进行组件扩展的前提下SpringCloud是基于Feign组件实现的RPC通信基于Http+Json序列化实现Dubbo是基于SPI扩展了很多RPC通信框架包括RMI、Dubbo、Hessian等RPC通信框架默认是Dubbo+Hessian序列化。不同的业务场景下RPC通信的选择和优化标准也不同。
例如开头我提到的我们部门在选择微服务框架时选择了Dubbo。当时的选择标准就是RPC通信可以支持抢购类的高并发在这个业务场景中请求的特点是瞬时高峰、请求量大和传入、传出参数数据包较小。而Dubbo中的Dubbo协议就很好地支持了这个请求。
**以下是基于Dubbo:2.6.4版本进行的简单的性能测试。**分别测试Dubbo+Protobuf序列化以及Http+Json序列化的通信性能这里主要模拟单一TCP长连接+Protobuf序列化和短连接的Http+Json序列化的性能对比。为了验证在数据量不同的情况下二者的性能表现我分别准备了小对象和大对象的性能压测通过这样的方式我们也可以间接地了解下二者在RPC通信方面的水平。
<img src="https://static001.geekbang.org/resource/image/dc/54/dc950f3a5ff15253e101fac90c192f54.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/20/b1/20814a2a87057fdc03af699454f703b1.jpg" alt="">
这个测试是我之前的积累,基于测试环境比较复杂,这里我就直接给出结果了,如果你感兴趣的话,可以留言和我讨论。
通过以上测试结果可以发现:**无论从响应时间还是吞吐量上来看单一TCP长连接+Protobuf序列化实现的RPC通信框架都有着非常明显的优势。**
在高并发场景下我们选择后端服务框架或者中间件部门自行设计服务框架时RPC通信是重点优化的对象。
其实目前成熟的RPC通信框架非常多如果你们公司没有自己的中间件团队也可以基于开源的RPC通信框架做扩展。在正式进行优化之前我们不妨简单回顾下RPC。
## 什么是RPC通信
一提到RPC你是否还想到MVC、SOA这些概念呢如果你没有经历过这些架构的演变这些概念就很容易混淆。**你可以通过下面这张图来了解下这些架构的演变史。**
<img src="https://static001.geekbang.org/resource/image/e4/a5/e43a8f81d76927948a73a9977643daa5.jpg" alt="">
无论是微服务、SOA、还是RPC架构它们都是分布式服务架构都需要实现服务之间的互相通信我们通常把这种通信统称为RPC通信。
RPCRemote Process Call即远程服务调用是通过网络请求远程计算机程序服务的通信技术。RPC框架封装好了底层网络通信、序列化等技术我们只需要在项目中引入各个服务的接口包就可以实现在代码中调用RPC服务同调用本地方法一样。正因为这种方便、透明的远程调用RPC被广泛应用于当下企业级以及互联网项目中是实现分布式系统的核心。
RMIRemote Method Invocation是JDK中最先实现了RPC通信的框架之一RMI的实现对建立分布式Java应用程序至关重要是Java体系非常重要的底层技术很多开源的RPC通信框架也是基于RMI实现原理设计出来的包括Dubbo框架中也接入了RMI框架。接下来我们就一起了解下RMI的实现原理看看它存在哪些性能瓶颈有待优化。
## RMIJDK自带的RPC通信框架
目前RMI已经很成熟地应用在了EJB以及Spring框架中是纯Java网络分布式应用系统的核心解决方案。RMI实现了一台虚拟机应用对远程方法的调用可以同对本地方法的调用一样RMI帮我们封装好了其中关于远程通信的内容。
### RMI的实现原理
RMI远程代理对象是RMI中最核心的组件除了对象本身所在的虚拟机其它虚拟机也可以调用此对象的方法。而且这些虚拟机可以不在同一个主机上通过远程代理对象远程应用可以用网络协议与服务进行通信。
我们可以通过一张图来详细地了解下整个RMI的通信过程
<img src="https://static001.geekbang.org/resource/image/11/4f/1113e44dd62591ce68961e017c11ed4f.jpg" alt="">
### RMI在高并发场景下的性能瓶颈
- Java默认序列化
RMI的序列化采用的是Java默认的序列化方式我在09讲中详细地介绍过Java序列化我们深知它的性能并不是很好而且其它语言框架也暂时不支持Java序列化。
- TCP短连接
由于RMI是基于TCP短连接实现在高并发情况下大量请求会带来大量连接的创建和销毁这对于系统来说无疑是非常消耗性能的。
- 阻塞式网络I/O
在08讲中我提到了网络通信存在I/O瓶颈如果在Socket编程中使用传统的I/O模型在高并发场景下基于短连接实现的网络通信就很容易产生I/O阻塞性能将会大打折扣。
## 一个高并发场景下的RPC通信优化路径
SpringCloud的RPC通信和RMI通信的性能瓶颈就非常相似。SpringCloud是基于Http通信协议短连接和Json序列化实现的在高并发场景下并没有优势。 那么在瞬时高并发的场景下我们又该如何去优化一个RPC通信呢
RPC通信包括了建立通信、实现报文、传输协议以及传输数据编解码等操作接下来我们就从每一层的优化出发逐步实现整体的性能优化。
### 1.选择合适的通信协议
要实现不同机器间的网络通信我们先要了解计算机系统网络通信的基本原理。网络通信是两台设备之间实现数据流交换的过程是基于网络传输协议和传输数据的编解码来实现的。其中网络传输协议有TCP、UDP协议这两个协议都是基于Socket编程接口之上为某类应用场景而扩展出的传输协议。通过以下两张图我们可以大概了解到基于TCP和UDP协议实现的Socket网络通信是怎样的一个流程。
<img src="https://static001.geekbang.org/resource/image/2c/0b/2c7c373963196a30e9d4fdc524a92d0b.jpg" alt="">
基于TCP协议实现的Socket通信是有连接的而传输数据是要通过三次握手来实现数据传输的可靠性且传输数据是没有边界的采用的是字节流模式。
基于UDP协议实现的Socket通信客户端不需要建立连接只需要创建一个套接字发送数据报给服务端这样就不能保证数据报一定会达到服务端所以在传输数据方面基于UDP协议实现的Socket通信具有不可靠性。UDP发送的数据采用的是数据报模式每个UDP的数据报都有一个长度该长度将与数据一起发送到服务端。
通过对比我们可以得出优化方法为了保证数据传输的可靠性通常情况下我们会采用TCP协议。如果在局域网且对数据传输的可靠性没有要求的情况下我们也可以考虑使用UDP协议毕竟这种协议的效率要比TCP协议高。
### 2.使用单一长连接
如果是基于TCP协议实现Socket通信我们还能做哪些优化呢
服务之间的通信不同于客户端与服务端之间的通信。客户端与服务端由于客户端数量多,基于短连接实现请求可以避免长时间地占用连接,导致系统资源浪费。
但服务之间的通信连接的消费端不会像客户端那么多但消费端向服务端请求的数量却一样多我们基于长连接实现就可以省去大量的TCP建立和关闭连接的操作从而减少系统的性能消耗节省时间。
### 3.优化Socket通信
建立两台机器的网络通信我们一般使用Java的Socket编程实现一个TCP连接。传统的Socket通信主要存在I/O阻塞、线程模型缺陷以及内存拷贝等问题。我们可以使用比较成熟的通信框架比如Netty。Netty4对Socket通信编程做了很多方面的优化具体见下方。
**实现非阻塞I/O**在08讲中我们提到了多路复用器Selector实现了非阻塞I/O通信。
**高效的Reactor线程模型**Netty使用了主从Reactor多线程模型服务端接收客户端请求连接是用了一个主线程这个主线程用于客户端的连接请求操作一旦连接建立成功将会监听I/O事件监听到事件后会创建一个链路请求。
链路请求将会注册到负责I/O操作的I/O工作线程上由I/O工作线程负责后续的I/O操作。利用这种线程模型可以解决在高负载、高并发的情况下由于单个NIO线程无法监听海量客户端和满足大量I/O操作造成的问题。
**串行设计:**服务端在接收消息之后存在着编码、解码、读取和发送等链路操作。如果这些操作都是基于并行去实现无疑会导致严重的锁竞争进而导致系统的性能下降。为了提升性能Netty采用了串行无锁化完成链路操作Netty提供了Pipeline实现链路的各个操作在运行期间不进行线程切换。
**零拷贝:**在08讲中我们提到了一个数据从内存发送到网络中存在着两次拷贝动作先是从用户空间拷贝到内核空间再是从内核空间拷贝到网络I/O中。而NIO提供的ByteBuffer可以使用Direct Buffers模式直接开辟一个非堆物理内存不需要进行字节缓冲区的二次拷贝可以直接将数据写入到内核空间。
**除了以上这些优化我们还可以针对套接字编程提供的一些TCP参数配置项提高网络吞吐量Netty可以基于ChannelOption来设置这些参数。**
**TCP_NODELAY**TCP_NODELAY选项是用来控制是否开启Nagle算法。Nagle算法通过缓存的方式将小的数据包组成一个大的数据包从而避免大量的小数据包发送阻塞网络提高网络传输的效率。我们可以关闭该算法优化对于时延敏感的应用场景。
**SO_RCVBUF和SO_SNDBUF**可以根据场景调整套接字发送缓冲区和接收缓冲区的大小。
**SO_BACKLOG**backlog参数指定了客户端连接请求缓冲队列的大小。服务端处理客户端连接请求是按顺序处理的所以同一时间只能处理一个客户端连接当有多个客户端进来的时候服务端就会将不能处理的客户端连接请求放在队列中等待处理。
**SO_KEEPALIVE**当设置该选项以后,连接会检查长时间没有发送数据的客户端的连接状态,检测到客户端断开连接后,服务端将回收该连接。我们可以将该时间设置得短一些,来提高回收连接的效率。
### 4.量身定做报文格式
接下来就是实现报文,我们需要设计一套报文,用于描述具体的校验、操作、传输数据等内容。为了提高传输的效率,我们可以根据自己的业务和架构来考虑设计,尽量实现报体小、满足功能、易解析等特性。我们可以参考下面的数据格式:
<img src="https://static001.geekbang.org/resource/image/6d/c1/6dc21193a6ffbf94a7dd8e5a0d2302c1.jpg" alt=""><br>
<img src="https://static001.geekbang.org/resource/image/f3/ae/f3bb46ed6ece4a8a9bcc3d9e9df84cae.jpg" alt="">
### 5.编码、解码
在09讲中我们分析过序列化编码和解码的过程对于实现一个好的网络通信协议来说兼容优秀的序列化框架是非常重要的。如果只是单纯的数据对象传输我们可以选择性能相对较好的Protobuf序列化有利于提高网络通信的性能。
### 6.调整Linux的TCP参数设置选项
如果RPC是基于TCP短连接实现的我们可以通过修改Linux TCP配置项来优化网络通信。开始TCP配置项的优化之前我们先来了解下建立TCP连接的三次握手和关闭TCP连接的四次握手这样有助后面内容的理解。
- 三次握手
<img src="https://static001.geekbang.org/resource/image/32/de/32381d3314bd982544f69e4d3faba1de.jpg" alt="">
- 四次握手
<img src="https://static001.geekbang.org/resource/image/df/91/df9f4e3f3598a7e160c899f552a59391.jpg" alt="">
我们可以通过sysctl -a | grep net.xxx命令运行查看Linux系统默认的的TCP参数设置如果需要修改某项配置可以通过编辑 vim/etc/sysctl.conf加入需要修改的配置项 并通过sysctl -p命令运行生效修改后的配置项设置。通常我们会通过修改以下几个配置项来提高网络吞吐量和降低延时。
<img src="https://static001.geekbang.org/resource/image/9e/bc/9eb01fe017b267367b11170a864bd0bc.jpg" alt="">
以上就是我们从不同层次对RPC优化的详解除了最后的Linux系统中TCP的配置项设置调优其它的调优更多是从代码编程优化的角度出发最终实现了一套RPC通信框架的优化路径。
弄懂了这些,你就可以根据自己的业务场景去做技术选型了,还能很好地解决过程中出现的一些性能问题。
## 总结
在现在的分布式系统中,特别是系统走向微服务化的今天,服务间的通信就显得尤为频繁,掌握服务间的通信原理和通信协议优化,是你的一项的必备技能。
在一些并发场景比较多的系统中我更偏向使用Dubbo实现的这一套RPC通信协议。Dubbo协议是建立的单一长连接通信网络I/O为NIO非阻塞读写操作更兼容了Kryo、FST、Protobuf等性能出众的序列化框架在高并发、小对象传输的业务场景中非常实用。
在企业级系统中业务往往要比普通的互联网产品复杂服务与服务之间可能不仅仅是数据传输还有图片以及文件的传输所以RPC的通信协议设计考虑更多是功能性需求在性能方面不追求极致。其它通信框架在功能性、生态以及易用、易入门等方面更具有优势。
## 思考题
目前实现Java RPC通信的框架有很多实现RPC通信的协议也有很多除了Dubbo协议以外你还使用过其它RPC通信协议吗通过这讲的学习你能对比谈谈各自的优缺点了吗
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。

View File

@@ -0,0 +1,219 @@
<audio id="audio" title="11 | 答疑课堂深入了解NIO的优化实现原理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/aa/7d/aa3c3bed09e0310e3ce0299221db307d.mp3"></audio>
你好我是刘超。专栏上线已经有20多天的时间了首先要感谢各位同学的积极留言交流的过程使我也收获良好。
综合查看完近期的留言以后我的第一篇答疑课堂就顺势诞生了。我将继续讲解I/O优化对大家在08讲中提到的内容做重点补充并延伸一些有关I/O的知识点更多结合实际场景进行分享。话不多说我们马上切入正题。
Tomcat中经常被提到的一个调优就是修改线程的I/O模型。Tomcat 8.5版本之前默认情况下使用的是BIO线程模型如果在高负载、高并发的场景下可以通过设置NIO线程模型来提高系统的网络通信性能。
我们可以通过一个性能对比测试来看看在高负载或高并发的情况下BIO和NIO通信性能这里用页面请求模拟多I/O读写操作的请求
<img src="https://static001.geekbang.org/resource/image/3e/4a/3e66a63ce9f0d9722005f78fa960244a.png" alt="">
<img src="https://static001.geekbang.org/resource/image/3e/74/3e1d942b7e5e09ad6e4757b8d5cbe274.png" alt="">
**测试结果Tomcat在I/O读写操作比较多的情况下使用NIO线程模型有明显的优势。**
Tomcat中看似一个简单的配置其中却包含了大量的优化升级知识点。下面我们就从底层的网络I/O模型优化出发再到内存拷贝优化和线程模型优化深入分析下Tomcat、Netty等通信框架是如何通过优化I/O来提高系统性能的。
## 网络I/O模型优化
网络通信中最底层的就是内核中的网络I/O模型了。随着技术的发展操作系统内核的网络模型衍生出了五种I/O模型《UNIX网络编程》一书将这五种I/O模型分为阻塞式I/O、非阻塞式I/O、I/O复用、信号驱动式I/O和异步I/O。每一种I/O模型的出现都是基于前一种I/O模型的优化升级。
最开始的阻塞式I/O它在每一个连接创建时都需要一个用户线程来处理并且在I/O操作没有就绪或结束时线程会被挂起进入阻塞等待状态阻塞式I/O就成为了导致性能瓶颈的根本原因。
**那阻塞到底发生在套接字socket通信的哪些环节呢**
在《Unix网络编程》中套接字通信可以分为流式套接字TCP和数据报套接字UDP。其中TCP连接是我们最常用的一起来了解下TCP服务端的工作流程由于TCP的数据传输比较复杂存在拆包和装包的可能这里我只假设一次最简单的TCP数据传输
<img src="https://static001.geekbang.org/resource/image/33/41/3310cc063aaf19f2aeb9ea5bc3188e41.jpg" alt="">
- 首先应用程序通过系统调用socket创建一个套接字它是系统分配给应用程序的一个文件描述符
- 其次应用程序会通过系统调用bind绑定地址和端口号给套接字命名一个名称
- 然后系统会调用listen创建一个队列用于存放客户端进来的连接
- 最后应用服务会通过系统调用accept来监听客户端的连接请求。
当有一个客户端连接到服务端之后服务端就会调用fork创建一个子进程通过系统调用read监听客户端发来的消息再通过write向客户端返回信息。
### 1.阻塞式I/O
在整个socket通信工作流程中socket的默认状态是阻塞的。也就是说当发出一个不能立即完成的套接字调用时其进程将被阻塞被系统挂起进入睡眠状态一直等待相应的操作响应。从上图中我们可以发现可能存在的阻塞主要包括以下三种。
**connect阻塞**当客户端发起TCP连接请求通过系统调用connect函数TCP连接的建立需要完成三次握手过程客户端需要等待服务端发送回来的ACK以及SYN信号同样服务端也需要阻塞等待客户端确认连接的ACK信号这就意味着TCP的每个connect都会阻塞等待直到确认连接。
<img src="https://static001.geekbang.org/resource/image/2a/c9/2a208cf7dddf18d2fe813b75ef2f4ac9.png" alt="">
**accept阻塞**一个阻塞的socket通信的服务端接收外来连接会调用accept函数如果没有新的连接到达调用进程将被挂起进入阻塞状态。
<img src="https://static001.geekbang.org/resource/image/3f/96/3fc5ef2eb6e594fd7d5cbc358cd5dd96.png" alt="">
**read、write阻塞**当一个socket连接创建成功之后服务端用fork函数创建一个子进程 调用read函数等待客户端的数据写入如果没有数据写入调用子进程将被挂起进入阻塞状态。
<img src="https://static001.geekbang.org/resource/image/e1/cf/e14386a357185acc39fdc708fb3692cf.png" alt="">
### 2.非阻塞式I/O
使用fcntl可以把以上三种操作都设置为非阻塞操作。如果没有数据返回就会直接返回一个EWOULDBLOCK或EAGAIN错误此时进程就不会一直被阻塞。
当我们把以上操作设置为了非阻塞状态我们需要设置一个线程对该操作进行轮询检查这也是最传统的非阻塞I/O模型。
<img src="https://static001.geekbang.org/resource/image/ec/62/ec2ec7914b3b86a1965dc98a830a3a62.png" alt="">
### 3. I/O复用
如果使用用户线程轮询查看一个I/O操作的状态在大量请求的情况下这对于CPU的使用率无疑是种灾难。 那么除了这种方式还有其它方式可以实现非阻塞I/O套接字吗
Linux提供了I/O复用函数select/poll/epoll进程将一个或多个读操作通过系统调用函数阻塞在函数操作上。这样系统内核就可以帮我们侦测多个读操作是否处于就绪状态。
**select()函数**它的用途是在超时时间内监听用户感兴趣的文件描述符上的可读可写和异常事件的发生。Linux 操作系统的内核将所有外部设备都看做一个文件来操作对一个文件的读写操作会调用内核提供的系统命令返回一个文件描述符fd
```
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
```
查看以上代码select() 函数监视的文件描述符分3类分别是writefds写文件描述符、readfds读文件描述符以及exceptfds异常事件文件描述符
调用后select() 函数会阻塞直到有描述符就绪或者超时函数返回。当select函数返回后可以通过函数FD_ISSET遍历fdset来找到就绪的描述符。fd_set可以理解为一个集合这个集合中存放的是文件描述符可通过以下四个宏进行设置
```
void FD_ZERO(fd_set *fdset); //清空集合
void FD_SET(int fd, fd_set *fdset); //将一个给定的文件描述符加入集合之中
void FD_CLR(int fd, fd_set *fdset); //将一个给定的文件描述符从集合中删除
int FD_ISSET(int fd, fd_set *fdset); // 检查集合中指定的文件描述符是否可以读写
```
<img src="https://static001.geekbang.org/resource/image/d8/dd/d8f4a9cfb8d37d08487a68fc10e31fdd.png" alt="">
**poll()函数**在每次调用select()函数之前系统需要把一个fd从用户态拷贝到内核态这样就给系统带来了一定的性能开销。再有单个进程监视的fd数量默认是1024我们可以通过修改宏定义甚至重新编译内核的方式打破这一限制。但由于fd_set是基于数组实现的在新增和删除fd时数量过大会导致效率降低。
poll() 的机制与 select() 类似二者在本质上差别不大。poll() 管理多个描述符也是通过轮询,根据描述符的状态进行处理,但 poll() 没有最大文件描述符数量的限制。
poll() 和 select() 存在一个相同的缺点,那就是包含大量文件描述符的数组被整体复制到用户态和内核的地址空间之间,而无论这些文件描述符是否就绪,他们的开销都会随着文件描述符数量的增加而线性增大。
<img src="https://static001.geekbang.org/resource/image/54/db/54d775cf7df756672b23a1853441d3db.png" alt="">
**epoll()函数**select/poll是顺序扫描fd是否就绪而且支持的fd数量不宜过大因此它的使用受到了一些制约。
Linux在2.6内核版本中提供了一个epoll调用epoll使用事件驱动的方式代替轮询扫描fd。epoll事先通过epoll_ctl()来注册一个文件描述符将文件描述符存放到内核的一个事件表中这个事件表是基于红黑树实现的所以在大量I/O请求的场景下插入和删除的性能比select/poll的数组fd_set要好因此epoll的性能更胜一筹而且不会受到fd数量的限制。
```
int epoll_ctl(int epfd, int op, int fd, struct epoll_event event)
```
**通过以上代码,我们可以看到:**epoll_ctl()函数中的epfd是由 epoll_create()函数生成的一个epoll专用文件描述符。op代表操作事件类型fd表示关联文件描述符event表示指定监听的事件类型。
一旦某个文件描述符就绪时内核会采用类似callback的回调机制迅速激活这个文件描述符当进程调用epoll_wait()时便得到通知之后进程将完成相关I/O操作。
```
int epoll_wait(int epfd, struct epoll_event events,int maxevents,int timeout)
```
<img src="https://static001.geekbang.org/resource/image/36/7a/36ee4e180cb6bb4f1452c0bafbe6f37a.png" alt="">
### 4.信号驱动式I/O
信号驱动式I/O类似观察者模式内核就是一个观察者信号回调则是通知。用户进程发起一个I/O请求操作会通过系统调用sigaction函数给对应的套接字注册一个信号回调此时不阻塞用户进程进程会继续工作。当内核数据就绪时内核就为该进程生成一个SIGIO信号通过信号回调通知进程进行相关I/O操作。
<img src="https://static001.geekbang.org/resource/image/4c/1f/4c897ce649d3deb609a4f0a9ba7aee1f.png" alt="">
信号驱动式I/O相比于前三种I/O模式实现了在等待数据就绪时进程不被阻塞主循环可以继续工作所以性能更佳。
而由于TCP来说信号驱动式I/O几乎没有被使用这是因为SIGIO信号是一种Unix信号信号没有附加信息如果一个信号源有多种产生信号的原因信号接收者就无法确定究竟发生了什么。而 TCP socket生产的信号事件有七种之多这样应用程序收到 SIGIO根本无从区分处理。
但信号驱动式I/O现在被用在了UDP通信上我们从10讲中的UDP通信流程图中可以发现UDP只有一个数据请求事件这也就意味着在正常情况下UDP进程只要捕获SIGIO信号就调用recvfrom读取到达的数据报。如果出现异常就返回一个异常错误。比如NTP服务器就应用了这种模型。
### 5.异步I/O
信号驱动式I/O虽然在等待数据就绪时没有阻塞进程但在被通知后进行的I/O操作还是阻塞的进程会等待数据从内核空间复制到用户空间中。而异步I/O则是实现了真正的非阻塞I/O。
当用户进程发起一个I/O请求操作系统会告知内核启动某个操作并让内核在整个操作完成后通知进程。这个操作包括等待数据就绪和数据从内核复制到用户空间。由于程序的代码复杂度高调试难度大且支持异步I/O的操作系统比较少见目前Linux暂不支持而Windows已经实现了异步I/O所以在实际生产环境中很少用到异步I/O模型。
<img src="https://static001.geekbang.org/resource/image/dd/c0/dd4b03afb56a3b7660794ce11fc421c0.png" alt="">
在08讲中我讲到了NIO使用I/O复用器Selector实现非阻塞I/OSelector就是使用了这五种类型中的I/O复用模型。Java中的Selector其实就是select/poll/epoll的外包类。
我们在上面的TCP通信流程中讲到Socket通信中的conect、accept、read以及write为阻塞操作在Selector中分别对应SelectionKey的四个监听事件OP_ACCEPT、OP_CONNECT、OP_READ以及OP_WRITE。
<img src="https://static001.geekbang.org/resource/image/85/96/85bcacec92e74c5cb6d6a39669e0d896.png" alt="">
在NIO服务端通信编程中首先会创建一个Channel用于监听客户端连接接着创建多路复用器Selector并将Channel注册到Selector程序会通过Selector来轮询注册在其上的Channel当发现一个或多个Channel处于就绪状态时返回就绪的监听事件最后程序匹配到监听事件进行相关的I/O操作。
<img src="https://static001.geekbang.org/resource/image/e2/15/e27534c5a157d0908d51b806919b1515.jpg" alt="">
在创建Selector时程序会根据操作系统版本选择使用哪种I/O复用函数。在JDK1.5版本中如果程序运行在Linux操作系统且内核版本在2.6以上NIO中会选择epoll来替代传统的select/poll这也极大地提升了NIO通信的性能。
由于信号驱动式I/O对TCP通信的不支持以及异步I/O在Linux操作系统内核中的应用还不大成熟大部分框架都还是基于I/O复用模型实现的网络通信。
## 零拷贝
在I/O复用模型中执行读写I/O操作依然是阻塞的在执行读写I/O操作时存在着多次内存拷贝和上下文切换给系统增加了性能开销。
零拷贝是一种避免多次内存复制的技术用来优化读写I/O操作。
在网络编程中通常由read、write来完成一次I/O读写操作。每一次I/O读写操作都需要完成四次内存拷贝路径是I/O设备-&gt;内核空间-&gt;用户空间-&gt;内核空间-&gt;其它I/O设备。
Linux内核中的mmap函数可以代替read、write的I/O读写操作实现用户空间和内核空间共享一个缓存数据。mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址不管是用户空间还是内核空间都是虚拟地址最终要通过地址映射映射到物理内存地址。这种方式避免了内核空间与用户空间的数据交换。I/O复用中的epoll函数中就是使用了mmap减少了内存拷贝。
在Java的NIO编程中则是使用到了Direct Buffer来实现内存的零拷贝。Java直接在JVM内存空间之外开辟了一个物理内存空间这样内核和用户进程都能共享一份缓存数据。这是在08讲中已经详细讲解过的内容你可以再去回顾下。
## 线程模型优化
除了内核对网络I/O模型的优化NIO在用户层也做了优化升级。NIO是基于事件驱动模型来实现的I/O操作。Reactor模型是同步I/O事件处理的一种常见模型其核心思想是将I/O事件注册到多路复用器上一旦有I/O事件触发多路复用器就会将事件分发到事件处理器中执行就绪的I/O事件操作。**该模型有以下三个主要组件:**
- 事件接收器Acceptor主要负责接收请求连接
- 事件分离器Reactor接收请求后会将建立的连接注册到分离器中依赖于循环监听多路复用器Selector一旦监听到事件就会将事件dispatch到事件处理器
- 事件处理器Handlers事件处理器主要是完成相关的事件处理比如读写I/O操作。
### 1.单线程Reactor线程模型
最开始NIO是基于单线程实现的所有的I/O操作都是在一个NIO线程上完成。由于NIO是非阻塞I/O理论上一个线程可以完成所有的I/O操作。
但NIO其实还不算真正地实现了非阻塞I/O操作因为读写I/O操作时用户进程还是处于阻塞状态这种方式在高负载、高并发的场景下会存在性能瓶颈一个NIO线程如果同时处理上万连接的I/O操作系统是无法支撑这种量级的请求的。
<img src="https://static001.geekbang.org/resource/image/29/f8/29b117cb60bcc8edd6d1fa21981fb9f8.png" alt="">
### 2.多线程Reactor线程模型
为了解决这种单线程的NIO在高负载、高并发场景下的性能瓶颈后来使用了线程池。
在Tomcat和Netty中都使用了一个Acceptor线程来监听连接请求事件当连接成功之后会将建立的连接注册到多路复用器中一旦监听到事件将交给Worker线程池来负责处理。大多数情况下这种线程模型可以满足性能要求但如果连接的客户端再上一个量级一个Acceptor线程可能会存在性能瓶颈。
<img src="https://static001.geekbang.org/resource/image/0d/82/0de0c467036f2973143a620448068a82.png" alt="">
### 3.主从Reactor线程模型
现在主流通信框架中的NIO通信框架都是基于主从Reactor线程模型来实现的。在这个模型中Acceptor不再是一个单独的NIO线程而是一个线程池。Acceptor接收到客户端的TCP连接请求建立连接之后后续的I/O操作将交给Worker I/O线程。
<img src="https://static001.geekbang.org/resource/image/f9/0a/f9d03620ae5c7c82c83f522710a62a0a.png" alt="">
### 基于线程模型的Tomcat参数调优
Tomcat中BIO、NIO是基于主从Reactor线程模型实现的。
**在BIO中**Tomcat中的Acceptor只负责监听新的连接一旦连接建立监听到I/O操作将会交给Worker线程中Worker线程专门负责I/O读写操作。
**在NIO中**Tomcat新增了一个Poller线程池Acceptor监听到连接后不是直接使用Worker中的线程处理请求而是先将请求发送给了Poller缓冲队列。在Poller中维护了一个Selector对象当Poller从队列中取出连接后注册到该Selector中然后通过遍历Selector找出其中就绪的I/O操作并使用Worker中的线程处理相应的请求。
<img src="https://static001.geekbang.org/resource/image/13/ba/136315be52782bd88056fb28f3ec60ba.png" alt="">
你可以通过以下几个参数来设置Acceptor线程池和Worker线程池的配置项。
**acceptorThreadCount**该参数代表Acceptor的线程数量在请求客户端的数据量非常巨大的情况下可以适当地调大该线程数量来提高处理请求连接的能力默认值为1。
**maxThreads**专门处理I/O操作的Worker线程数量默认是200可以根据实际的环境来调整该参数但不一定越大越好。
**acceptCount**Tomcat的Acceptor线程是负责从accept队列中取出该connection然后交给工作线程去执行相关操作这里的acceptCount指的是accept队列的大小。
当Http关闭keep alive在并发量比较大时可以适当地调大这个值。而在Http开启keep alive时因为Worker线程数量有限Worker线程就可能因长时间被占用而连接在accept队列中等待超时。如果accept队列过大就容易浪费连接。
**maxConnections**表示有多少个socket连接到Tomcat上。在BIO模式中一个线程只能处理一个连接一般maxConnections与maxThreads的值大小相同在NIO模式中一个线程同时处理多个连接maxConnections应该设置得比maxThreads要大的多默认是10000。
今天的内容比较多,看到这里不知道你消化得如何?如果还有疑问,请在留言区中提出,我们共同探讨。最后欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他加入讨论。

View File

@@ -0,0 +1,122 @@
<audio id="audio" title="加餐 | 推荐几款常用的性能测试工具" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/c0/afcc5d1c597fd4c852565dff3478d6c0.mp3"></audio>
你好,我是刘超。很多同学给我留言想让我讲讲工具,所以我的第一篇加餐就光速来了~
熟练掌握一款性能测试工具,是我们必备的一项技能。他不仅可以帮助我们模拟测试场景(包括并发、复杂的组合场景),还能将测试结果转化成数据或图形,帮助我们更直观地了解系统性能。
## 常用的性能测试工具
常用的性能测试工具有很多,在这里我将列举几个比较实用的。
对于开发人员来说首选是一些开源免费的性能压力测试软件例如abApacheBench、JMeter等对于专业的测试团队来说付费版的LoadRunner是首选。当然也有很多公司是自行开发了一套量身定做的性能测试软件优点是定制化强缺点则是通用性差。
接下来我会为你重点介绍ab和JMeter两款测试工具的特点以及常规的使用方法。
### 1.ab
ab测试工具是Apache提供的一款测试工具具有简单易上手的特点在测试Web服务时非常实用。
ab可以在Windows系统中使用也可以在Linux系统中使用。这里我说下在Linux系统中的安装方法非常简单只需要在Linux系统中输入yum-y install httpd-tools命令就可以了。
安装成功后输入ab命令可以看到以下提示
<img src="https://static001.geekbang.org/resource/image/ac/0a/ac58706f86ebd1d7349561ae501fca0a.png" alt="">
ab工具用来测试post get接口请求非常便捷可以通过参数指定请求数、并发数、请求参数等。例如一个测试并发用户数为10、请求数量为100的的post请求输入如下
```
ab -n 100 -c 10 -p 'post.txt' -T 'application/x-www-form-urlencoded' 'http://test.api.com/test/register'
```
post.txt为存放post参数的文档存储格式如下
```
usernanme=test&amp;password=test&amp;sex=1
```
**附上几个常用参数的含义:**
- -n总请求次数最小默认为1
- -c并发次数最小默认为1且不能大于总请求次数例如10个请求10个并发实际就是1人请求1次
- -ppost参数文档路径-p和-T参数要配合使用
- -Theader头内容类型此处切记是大写英文字母T
当我们测试一个get请求接口时可以直接在链接的后面带上请求的参数
```
ab -c 10 -n 100 http://www.test.api.com/test/login?userName=test&amp;password=test
```
输出结果如下:
<img src="https://static001.geekbang.org/resource/image/66/9b/66e7cf2dafa91a3ae80405f97a91899b.png" alt="">
**以上输出中,有几项性能指标可以提供给你参考使用:**
- Requests per second吞吐率指某个并发用户数下单位时间内处理的请求数
- Time per request上面的是用户平均请求等待时间指处理完成所有请求数所花费的时间/(总请求数/并发用户数);
- Time per request下面的是服务器平均请求处理时间指处理完成所有请求数所花费的时间/总请求数;
- Percentage of the requests served within a certain time每秒请求时间分布情况指在整个请求中每个请求的时间长度的分布情况例如有50%的请求响应在8ms内66%的请求响应在10ms内说明有16%的请求在8ms~10ms之间。
### 2.JMeter
JMeter是Apache提供的一款功能性比较全的性能测试工具同样可以在Windows和Linux环境下安装使用。
JMeter在Windows环境下使用了图形界面可以通过图形界面来编写测试用例具有易学和易操作的特点。
JMeter不仅可以实现简单的并发性能测试还可以实现复杂的宏基准测试。我们可以通过录制脚本的方式在JMeter实现整个业务流程的测试。JMeter也支持通过csv文件导入参数变量实现用多样化的参数测试系统性能。
Windows下的JMeter安装非常简单在官网下载安装包解压后即可使用。如果你需要打开图形化界面那就进入到bin目录下找到jmeter.bat文件双击运行该文件就可以了。
<img src="https://static001.geekbang.org/resource/image/2d/53/2d96660e8e88a2697e066fd301663153.png" alt="">
JMeter的功能非常全面我在这里简单介绍下如何录制测试脚本并使用JMeter测试业务的性能。
录制JMeter脚本的方法有很多一种是使用Jmeter自身的代理录制另一种是使用Badboy这款软件录制还有一种是我下面要讲的通过安装浏览器插件的方式实现脚本的录制这种方式非常简单不用做任何设置。
首先我们安装一个录制测试脚本的插件叫做BlazeMeter插件。你可以在Chrome应用商店中找到它然后点击安装 如图所示:
<img src="https://static001.geekbang.org/resource/image/a8/3e/a8f7403c1b6b720318d97accf191843e.png" alt="">
然后使用谷歌账号登录这款插件如果不登录我们将无法生成JMeter文件安装以及登录成功后的界面如下图所示
<img src="https://static001.geekbang.org/resource/image/29/4a/2932afaf9eecb2cce789ad5151180a4a.png" alt="">
最后点击开始就可以录制脚本了。录制成功后点击保存为JMX文件我们就可以通过JMeter打开这个文件看到录制的脚本了如下图所示
<img src="https://static001.geekbang.org/resource/image/bf/fd/bf03e37ace494cf84171b55f9b63bdfd.png" alt="">
这个时候,我们还需要创建一个查看结果树,用来可视化查看运行的性能结果集合:
<img src="https://static001.geekbang.org/resource/image/84/69/844a2a65add49c2f4d15b10667943069.png" alt="">
设置好结果树之后,我们可以对线程组的并发用户数以及循环调用次数进行设置:
<img src="https://static001.geekbang.org/resource/image/43/9b/431ae410ec4369cc81af7622a23b409b.png" alt="">
设置成功之后,点击运行,我们可以看到运行的结果:
<img src="https://static001.geekbang.org/resource/image/6f/67/6ffe85677e50bb75152d45526a7ba667.png" alt="">
JMeter的测试结果与ab的测试结果的指标参数差不多这里我就不再重复讲解了。
### 3.LoadRunner
LoadRunner是一款商业版的测试工具并且License的售价不低。
作为一款专业的性能测试工具LoadRunner在性能压测时表现得非常稳定和高效。相比JMeterLoadRunner可以模拟出不同的内网IP地址通过分配不同的IP地址给测试的用户模拟真实环境下的用户。这里我就不展开详述了。
## 总结
三种常用的性能测试工具就介绍完了,最后我把今天的主要内容为你总结了一张图。
<img src="https://static001.geekbang.org/resource/image/a7/1a/a70d0081607081471df4db435641b51a.jpg" alt="">
现在测试工具非常多包括阿里云的PTS测试工具也很好用但每款测试工具其实都有自己的优缺点。个人建议还是在熟练掌握其中一款测试工具的前提下再去探索其他测试工具的使用方法会更好。
今天的加餐到这里就结束了,如果你有其他疑问或者更多想要了解的内容,欢迎留言告诉我。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。