mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 14:13:46 +08:00
mod
This commit is contained in:
@@ -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= "abc";
|
||||
String str2= new String("abc");
|
||||
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= "ab" + "cd" + "ef";
|
||||
|
||||
```
|
||||
|
||||
分析代码可知:首先会生成ab对象,再生成abcd对象,最后生成abcdef对象,从理论上来说,这段代码是低效的。
|
||||
|
||||
但实际运行中,我们发现只有一个对象生成,这是为什么呢?难道我们的理论判断错了?我们再来看编译后的代码,你会发现编译器自动优化了这行代码,如下:
|
||||
|
||||
```
|
||||
String str= "abcdef";
|
||||
|
||||
```
|
||||
|
||||
上面我介绍的是字符串常量的累计,我们再来看看字符串变量的累计又是怎样的呢?
|
||||
|
||||
```
|
||||
String str = "abcdef";
|
||||
|
||||
for(int i=0; i<1000; i++) {
|
||||
str = str + i;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面的代码编译后,你可以看到编译器同样对这段代码进行了优化。不难发现,Java在进行字符串的拼接时,偏向使用StringBuilder,这样可以提高程序的效率。
|
||||
|
||||
```
|
||||
|
||||
String str = "abcdef";
|
||||
|
||||
for(int i=0; i<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("abc").intern();
|
||||
String b = new String("abc").intern();
|
||||
|
||||
if(a==b) {
|
||||
System.out.print("a==b");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
输出结果:
|
||||
|
||||
```
|
||||
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讲中为你详解。
|
||||
|
||||
## 思考题
|
||||
|
||||
通过今天的学习,你知道文章开头那道面试题的答案了吗?背后的原理是什么?
|
||||
|
||||
## 互动时刻
|
||||
|
||||
今天除了思考题,我还想和你做一个简短的交流。
|
||||
|
||||
上两讲中,我收到了很多留言,在此非常感谢你的支持。由于前两讲是概述内容,主要是帮你建立对性能调优的整体认识,所以相对来说重理论、偏基础。但我发现,很多同学都有这样迫切的愿望,那就是赶紧学会使用排查工具,监测分析性能,解决当下的一些问题。
|
||||
|
||||
我这里特别想分享一点,其实性能调优不仅仅是学会使用排查监测工具,更重要的是掌握背后的调优原理,这样你不仅能够独立解决同一类的性能问题,还能写出高性能代码,所以我希望给你的学习路径是:夯实基础-结合实战-实现进阶。
|
||||
|
||||
最后,欢迎你积极发言,讨论思考题或是你遇到的性能问题都可以,我会知无不尽。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
|
||||
|
||||
|
||||
280
极客时间专栏/Java性能调优实战/模块二 · Java编程性能调优/04 | 慎重使用正则表达式.md
Normal file
280
极客时间专栏/Java性能调优实战/模块二 · Java编程性能调优/04 | 慎重使用正则表达式.md
Normal 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,则该匹配算法的时间复杂度为O(ns)。
|
||||
|
||||
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-~_=%]++\\&{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 = "<input high=\"20\" weight=\"70\">test</input>";
|
||||
String reg="(<input.*?>)(.*?)(</input>)";
|
||||
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));//(<input.*?>)
|
||||
System.out.println(m.group(2));//(.*?)
|
||||
System.out.println(m.group(3));//(</input>)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行结果:
|
||||
|
||||
```
|
||||
<input high=\"20\" weight=\"70\">test</input>
|
||||
<input high=\"20\" weight=\"70\">
|
||||
test
|
||||
</input>
|
||||
|
||||
```
|
||||
|
||||
如果你并不需要获取某一个分组内的文本,那么就使用非捕获分组。例如,使用“(?:X)”代替“(X)”,我们再看下面的例子:
|
||||
|
||||
```
|
||||
public static void main( String[] args )
|
||||
{
|
||||
String text = "<input high=\"20\" weight=\"70\">test</input>";
|
||||
String reg="(?:<input.*?>)(.*?)(?:</input>)";
|
||||
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));//(.*?)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行结果:
|
||||
|
||||
```
|
||||
<input high=\"20\" weight=\"70\">test</input>
|
||||
test
|
||||
|
||||
```
|
||||
|
||||
综上可知:减少不需要获取的分组,可以提高正则表达式的性能。
|
||||
|
||||
## 总结
|
||||
|
||||
正则表达式虽然小,却有着强大的匹配功能。我们经常用到它,比如,注册页面手机号或邮箱的校验。
|
||||
|
||||
但很多时候,我们又会因为它小而忽略它的使用规则,测试用例中又没有覆盖到一些特殊用例,不乏上线就中招的情况发生。
|
||||
|
||||
综合我以往的经验来看,如果使用正则表达式能使你的代码简洁方便,那么在做好性能排查的前提下,可以去使用;如果不能,那么正则表达式能不用就不用,以此避免造成更多的性能问题。
|
||||
|
||||
## 思考题
|
||||
|
||||
除了Split()方法使用到正则表达式,其实Java还有一些方法也使用了正则表达式去实现一些功能,使我们很容易掉入陷阱。现在就请你想一想JDK里面,还有哪些工具方法用到了正则表达式?
|
||||
|
||||
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/67/bbe343640d6b708832c4133ec53ed967.jpg" alt="unpreview">
|
||||
@@ -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<E> extends AbstractList<E>
|
||||
implements List<E>, 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 > 0) {
|
||||
this.elementData = new Object[initialCapacity];
|
||||
} else if (initialCapacity == 0) {//初始化容量为零时,使用默认的空数组
|
||||
this.elementData = EMPTY_ELEMENTDATA;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Illegal Capacity: "+
|
||||
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 > 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 >> 1);
|
||||
if (newCapacity - minCapacity < 0)
|
||||
newCapacity = minCapacity;
|
||||
if (newCapacity - MAX_ARRAY_SIZE > 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 > 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<E> {
|
||||
E item;
|
||||
Node<E> next;
|
||||
Node<E> prev;
|
||||
|
||||
Node(Node<E> prev, E element, Node<E> 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<E>
|
||||
extends AbstractSequentialList<E>
|
||||
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
|
||||
|
||||
```
|
||||
|
||||
### 2.LinkedList属性
|
||||
|
||||
我们前面讲到了LinkedList的两个重要属性first/last属性,其实还有一个size属性。我们可以看到这三个属性都被transient修饰了,原因很简单,我们在序列化的时候不会只对头尾进行序列化,所以LinkedList也是自行实现readObject和writeObject进行序列化与反序列化。
|
||||
|
||||
```
|
||||
transient int size = 0;
|
||||
transient Node<E> first;
|
||||
transient Node<E> 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<E> l = last;
|
||||
final Node<E> newNode = new Node<>(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<E> succ) {
|
||||
// assert succ != null;
|
||||
final Node<E> pred = succ.prev;
|
||||
final Node<E> newNode = new Node<>(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>LinkedList
|
||||
- ArrayList<LinkedList
|
||||
- ArrayList<LinkedList
|
||||
|
||||
通过这组测试,我们可以知道LinkedList添加元素的效率未必要高于ArrayList。
|
||||
|
||||
由于ArrayList是数组实现的,而数组是一块连续的内存空间,在添加元素到数组头部的时候,需要对头部以后的数据进行复制重排,所以效率很低;而LinkedList是基于链表实现,在添加元素的时候,首先会通过循环查找到添加元素的位置,如果要添加的位置处于List的前半段,就从前往后找;若其位置处于后半段,就从后往前找。因此LinkedList添加元素到头部是非常高效的。
|
||||
|
||||
同上可知,ArrayList在添加元素到数组中间时,同样有部分数据需要复制重排,效率也不是很高;LinkedList将元素添加到中间位置,是添加元素最低效率的,因为靠近中间位置,在添加元素之前的循环查找是遍历元素最多的操作。
|
||||
|
||||
而在添加元素到尾部的操作中,我们发现,在没有扩容的情况下,ArrayList的效率要高于LinkedList。这是因为ArrayList在添加元素到尾部的时候,不需要复制重排数据,效率非常高。而LinkedList虽然也不用循环查找元素,但LinkedList中多了new对象以及变换指针指向对象的过程,所以效率要低于ArrayList。
|
||||
|
||||
说明一下,这里我是基于ArrayList初始化容量足够,排除动态扩容数组容量的情况下进行的测试,如果有动态扩容的情况,ArrayList的效率也会降低。
|
||||
|
||||
**2.ArrayList和LinkedList删除元素操作测试**
|
||||
|
||||
- 从集合头部位置删除元素
|
||||
- 从集合中间位置删除元素
|
||||
- 从集合尾部位置删除元素
|
||||
|
||||
测试结果(花费时间):
|
||||
|
||||
- ArrayList>LinkedList
|
||||
- ArrayList<LinkedList
|
||||
- ArrayList<LinkedList
|
||||
|
||||
ArrayList和LinkedList删除元素操作测试的结果和添加元素操作测试的结果很接近,这是一样的原理,我在这里就不重复讲解了。
|
||||
|
||||
**3.ArrayList和LinkedList遍历元素操作测试**
|
||||
|
||||
- for(;;)循环
|
||||
- 迭代器迭代循环
|
||||
|
||||
测试结果(花费时间):
|
||||
|
||||
- ArrayList<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<String> list = new ArrayList<String>();
|
||||
list.add("a");
|
||||
list.add("a");
|
||||
list.add("b");
|
||||
list.add("b");
|
||||
list.add("c");
|
||||
list.add("c");
|
||||
remove(list);//删除指定的“b”元素
|
||||
|
||||
for(int i=0; i<list.size(); i++)("c")()()(s : list)
|
||||
{
|
||||
System.out.println("element : " + s)list.get(i)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从上面的代码来看,我定义了一个ArrayList数组,里面添加了一些元素,然后我通过remove删除指定的元素。请问以下两种写法,哪种是正确的?
|
||||
|
||||
写法1:
|
||||
|
||||
```
|
||||
public static void remove(ArrayList<String> list)
|
||||
{
|
||||
Iterator<String> it = list.iterator();
|
||||
|
||||
while (it.hasNext()) {
|
||||
String str = it.next();
|
||||
|
||||
if (str.equals("b")) {
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
写法2:
|
||||
|
||||
```
|
||||
public static void remove(ArrayList<String> list)
|
||||
{
|
||||
for (String s : list)
|
||||
{
|
||||
if (s.equals("b"))
|
||||
{
|
||||
list.remove(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
|
||||
359
极客时间专栏/Java性能调优实战/模块二 · Java编程性能调优/06 | Stream如何提高遍历集合效率?.md
Normal file
359
极客时间专栏/Java性能调优实战/模块二 · Java编程性能调优/06 | Stream如何提高遍历集合效率?.md
Normal 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<String, List<Student>> stuMap = new HashMap<String, List<Student>>();
|
||||
for (Student stu: studentsList) {
|
||||
if (stu.getHeight() > 160) { //如果身高大于160
|
||||
if (stuMap.get(stu.getSex()) == null) { //该性别还没分类
|
||||
List<Student> list = new ArrayList<Student>(); //新建该性别学生的列表
|
||||
list.add(stu);//将学生放进去列表
|
||||
stuMap.put(stu.getSex(), list);//将列表放到map中
|
||||
} else { //该性别分类已存在
|
||||
stuMap.get(stu.getSex()).add(stu);//该性别分类已存在,则直接放进去即可
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
我们再使用Java8中的Stream API进行实现:
|
||||
|
||||
1.串行实现
|
||||
|
||||
```
|
||||
Map<String, List<Student>> stuMap = stuList.stream().filter((Student s) -> s.getHeight() > 160) .collect(Collectors.groupingBy(Student ::getSex));
|
||||
|
||||
```
|
||||
|
||||
2.并行实现
|
||||
|
||||
```
|
||||
Map<String, List<Student>> stuMap = stuList.parallelStream().filter((Student s) -> s.getHeight() > 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<String> names = Arrays.asList("张三", "李四", "王老五", "李三", "刘老四", "王小二", "张四", "张五六七");
|
||||
|
||||
String maxLenStartWithZ = names.stream()
|
||||
.filter(name -> name.startsWith("张"))
|
||||
.mapToInt(String::length)
|
||||
.max()
|
||||
.toString();
|
||||
|
||||
```
|
||||
|
||||
这个例子的需求是查找出一个长度最长,并且以张为姓氏的名字。从代码角度来看,你可能会认为是这样的操作流程:首先遍历一次集合,得到以“张”开头的所有名字;然后遍历一次filter得到的集合,将名字转换成数字长度;最后再从长度集合中找到最长的那个名字并且返回。
|
||||
|
||||
这里我要很明确地告诉你,实际情况并非如此。我们来逐步分析下这个方法里所有的操作是如何执行的。
|
||||
|
||||
首先 ,因为names是ArrayList集合,所以names.stream()方法将会调用集合类基础接口Collection的Stream方法:
|
||||
|
||||
```
|
||||
default Stream<E> stream() {
|
||||
return StreamSupport.stream(spliterator(), false);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,Stream方法就会调用StreamSupport类的Stream方法,方法中初始化了一个ReferencePipeline的Head内部类对象:
|
||||
|
||||
```
|
||||
public static <T> Stream<T> stream(Spliterator<T> spliterator, boolean parallel) {
|
||||
Objects.requireNonNull(spliterator);
|
||||
return new ReferencePipeline.Head<>(spliterator,
|
||||
StreamOpFlag.fromCharacteristics(spliterator),
|
||||
parallel);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
再调用filter和map方法,这两个方法都是无状态的中间操作,所以执行filter和map操作时,并没有进行任何的操作,而是分别创建了一个Stage来标识用户的每一次操作。
|
||||
|
||||
而通常情况下Stream的操作又需要一个回调函数,所以一个完整的Stage是由数据来源、操作、回调函数组成的三元组来表示。如下图所示,分别是ReferencePipeline的filter方法和map方法:
|
||||
|
||||
```
|
||||
@Override
|
||||
public final Stream<P_OUT> filter(Predicate<? super P_OUT> predicate) {
|
||||
Objects.requireNonNull(predicate);
|
||||
return new StatelessOp<P_OUT, P_OUT>(this, StreamShape.REFERENCE,
|
||||
StreamOpFlag.NOT_SIZED) {
|
||||
@Override
|
||||
Sink<P_OUT> opWrapSink(int flags, Sink<P_OUT> sink) {
|
||||
return new Sink.ChainedReference<P_OUT, P_OUT>(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("unchecked")
|
||||
public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
|
||||
Objects.requireNonNull(mapper);
|
||||
return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
|
||||
StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
|
||||
@Override
|
||||
Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
|
||||
return new Sink.ChainedReference<P_OUT, R>(sink) {
|
||||
@Override
|
||||
public void accept(P_OUT u) {
|
||||
downstream.accept(mapper.apply(u));
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
new StatelessOp将会调用父类AbstractPipeline的构造函数,这个构造函数将前后的Stage联系起来,生成一个Stage链表:
|
||||
|
||||
```
|
||||
AbstractPipeline(AbstractPipeline<?, E_IN, ?> 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 & 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采用(处理->转发)的模式来叠加操作。
|
||||
|
||||
当执行max方法时,会调用ReferencePipeline的max方法,此时由于max方法是终结操作,所以会创建一个TerminalOp操作,同时创建一个ReducingSink,并且将操作封装在Sink类中。
|
||||
|
||||
```
|
||||
@Override
|
||||
public final Optional<P_OUT> max(Comparator<? super P_OUT> comparator) {
|
||||
return reduce(BinaryOperator.maxBy(comparator));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,调用AbstractPipeline的wrapSink方法,该方法会调用opWrapSink生成一个Sink链表,Sink链表中的每一个Sink都封装了一个操作的具体实现。
|
||||
|
||||
```
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
|
||||
Objects.requireNonNull(sink);
|
||||
|
||||
for ( @SuppressWarnings("rawtypes") AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
|
||||
sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
|
||||
}
|
||||
return (Sink<P_IN>) sink;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
当Sink链表生成完成后,Stream开始执行,通过spliterator迭代集合,执行Sink链表中的具体操作。
|
||||
|
||||
```
|
||||
@Override
|
||||
final <P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> 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<String> names = Arrays.asList("张三", "李四", "王老五", "李三", "刘老四", "王小二", "张四", "张五六七");
|
||||
|
||||
String maxLenStartWithZ = names.stream()
|
||||
.parallel()
|
||||
.filter(name -> name.startsWith("张"))
|
||||
.mapToInt(String::length)
|
||||
.max()
|
||||
.toString();
|
||||
|
||||
```
|
||||
|
||||
Stream的并行处理在执行终结操作之前,跟串行处理的实现是一样的。而在调用终结方法之后,实现的方式就有点不太一样,会调用TerminalOp的evaluateParallel方法进行并行处理。
|
||||
|
||||
```
|
||||
final <R> R evaluate(TerminalOp<E_OUT, R> 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)上查看。通过以上测试,我统计出的测试结果如下(迭代使用时间):
|
||||
|
||||
- 常规的迭代<Stream并行迭代<Stream串行迭代
|
||||
- Stream并行迭代<常规的迭代<Stream串行迭代
|
||||
- Stream并行迭代<常规的迭代<Stream串行迭代
|
||||
- 常规的迭代<Stream串行迭代<Stream并行迭代
|
||||
|
||||
通过以上测试结果,我们可以看到:在循环迭代次数较少的情况下,常规的迭代方式性能反而更好;在单核CPU服务器配置环境中,也是常规迭代方式更有优势;而在大数据循环迭代中,如果服务器是多核CPU的情况下,Stream的并行迭代优势明显。所以我们在平时处理大数据的集合时,应该尽量考虑将应用部署在多核CPU环境下,并且使用Stream的并行迭代方式进行处理。
|
||||
|
||||
用事实说话,我们看到其实使用Stream未必可以使系统性能更佳,还是要结合应用场景进行选择,也就是合理地使用Stream。
|
||||
|
||||
## 总结
|
||||
|
||||
纵观Stream的设计实现,非常值得我们学习。从大的设计方向上来说,Stream将整个操作分解为了链式结构,不仅简化了遍历操作,还为实现了并行计算打下了基础。
|
||||
|
||||
从小的分类方向上来说,Stream将遍历元素的操作和对元素的计算分为中间操作和终结操作,而中间操作又根据元素之间状态有无干扰分为有状态和无状态操作,实现了链结构中的不同阶段。
|
||||
|
||||
**在串行处理操作中,**Stream在执行每一步中间操作时,并不会做实际的数据操作处理,而是将这些中间操作串联起来,最终由终结操作触发,生成一个数据处理链表,通过Java8中的Spliterator迭代器进行数据处理;此时,每执行一次迭代,就对所有的无状态的中间操作进行数据处理,而对有状态的中间操作,就需要迭代处理完所有的数据,再进行处理操作;最后就是进行终结操作的数据处理。
|
||||
|
||||
**在并行处理操作中,**Stream对中间操作基本跟串行处理方式是一样的,但在终结操作中,Stream将结合ForkJoin框架对集合进行切片处理,ForkJoin框架将每个切片的处理结果Join合并起来。最后就是要注意Stream的使用场景。
|
||||
|
||||
## 思考题
|
||||
|
||||
这里有一个简单的并行处理案例,请你找出其中存在的问题。
|
||||
|
||||
```
|
||||
//使用一个容器装载100个数字,通过Stream并行处理的方式将容器中为单数的数字转移到容器parallelList
|
||||
List<Integer> integerList= new ArrayList<Integer>();
|
||||
|
||||
for (int i = 0; i <100; i++) {
|
||||
integerList.add(i);
|
||||
}
|
||||
|
||||
List<Integer> parallelList = new ArrayList<Integer>() ;
|
||||
integerList.stream()
|
||||
.parallel()
|
||||
.filter(i->i%2==1)
|
||||
.forEach(i->parallelList.add(i));
|
||||
|
||||
|
||||
```
|
||||
|
||||
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
|
||||
232
极客时间专栏/Java性能调优实战/模块二 · Java编程性能调优/07 | 深入浅出HashMap的设计与优化.md
Normal file
232
极客时间专栏/Java性能调优实战/模块二 · Java编程性能调优/07 | 深入浅出HashMap的设计与优化.md
Normal 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)直接进行访问的数据结构。通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做哈希函数,存放记录的数组就叫做哈希表。
|
||||
|
||||
**树**:由n(n≥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<K,V>[] table;
|
||||
|
||||
```
|
||||
|
||||
Node类作为HashMap中的一个内部类,除了key、value两个属性外,还定义了一个next指针。当有哈希冲突时,HashMap会用之前数组当中相同哈希值对应存储的Node对象,通过指针指向新增的相同哈希值的Node对象的引用。
|
||||
|
||||
```
|
||||
static class Node<K,V> implements Map.Entry<K,V> {
|
||||
final int hash;
|
||||
final K key;
|
||||
V value;
|
||||
Node<K,V> next;
|
||||
|
||||
Node(int hash, K key, V value, Node<K,V> 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) & 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 >>> 16);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
if ((tab = table) == null || (n = tab.length) == 0)
|
||||
n = (tab = resize()).length;
|
||||
//通过putVal方法中的(n - 1) & hash决定该Node的存储位置
|
||||
if ((p = tab[i = (n - 1) & hash]) == null)
|
||||
tab[i] = newNode(hash, key, value, null);
|
||||
|
||||
|
||||
```
|
||||
|
||||
如果你不太清楚hash()以及(n-1)&hash的算法,就请你看下面的详述。
|
||||
|
||||
我们先来了解下hash()方法中的算法。如果我们没有使用hash()方法计算hashCode,而是直接使用对象的hashCode值,会出现什么问题呢?
|
||||
|
||||
假设要添加两个对象a和b,如果数组长度是16,这时对象a和b通过公式(n - 1) & hash运算,也就是(16-1)&a.hashCode和(16-1)&b.hashCode,15的二进制为0000000000000000000000000001111,假设对象 A 的 hashCode 为 1000010001110001000001111000000,对象 B 的 hashCode 为 0111011100111000101000010100000,你会发现上述与运算结果都是0。这样的哈希结果就太让人失望了,很明显不是一个好的哈希算法。
|
||||
|
||||
但如果我们将 hashCode 值右移 16 位(h >>> 16代表无符号右移16位),也就是取 int 类型的一半,刚好可以将该二进制数对半切开,并且使用位异或运算(如果两个数对应的位置相反,则结果为1,反之为0),这样的话,就能避免上面的情况发生。这就是hash()方法的具体实现方式。**简而言之,就是尽量打乱hashCode真正参与运算的低16位。**
|
||||
|
||||
我再来解释下(n - 1) & hash是怎么设计的,这里的n代表哈希表的长度,哈希表习惯将长度设置为2的n次方,这样恰好可以保证(n - 1) & hash的计算得到的索引值总是位于table数组的索引之内。例如:hash=15,n=16时,结果为15;hash=17,n=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<K,V>[] tab; Node<K,V> 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) & hash]) == null)
|
||||
//1.1、此处通过(n - 1) & 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<K,V> e; K k;
|
||||
if (p.hash == hash &&
|
||||
((k = p.key) == key || (key != null && 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<K,V>)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 >= TREEIFY_THRESHOLD - 1)
|
||||
// 插入成功后,要判断是否需要转换为红黑树,因为插入后链表长度加1,而binCount并不包含新节点,所以判断时要将临界阈值减1
|
||||
treeifyBin(tab, hash);
|
||||
//当新长度满足转换条件时,调用treeifyBin方法,将该链表转换为红黑树
|
||||
break;
|
||||
}
|
||||
if (e.hash == hash &&
|
||||
((k = e.key) == key || (key != null && 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 > 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的整数次幂。你知道原因吗?
|
||||
|
||||
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
|
||||
@@ -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 <= lim);
|
||||
int rem = (pos <= 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/O,NIO适用于发生大量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呢?
|
||||
|
||||
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
|
||||
@@ -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 < 100; i++) {
|
||||
Set t1 = new HashSet();
|
||||
Set t2 = new HashSet();
|
||||
t1.add("foo"); //使t2不等于t1
|
||||
s1.add(t1);
|
||||
s1.add(t2);
|
||||
s2.add(t1);
|
||||
s2.add(t2);
|
||||
s1 = t1;
|
||||
s2 = t2;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
2015年FoxGlove Security安全团队的breenmachine发布过一篇长博客,主要内容是:通过Apache Commons Collections,Java反序列化漏洞可以实现攻击。一度横扫了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(
|
||||
"Unauthorized deserialization attempt", desc.getName());
|
||||
}
|
||||
return super.resolveClass(desc);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 3.序列化后的流太大
|
||||
|
||||
序列化后的二进制流大小能体现序列化的性能。序列化后的二进制数组越大,占用的存储空间就越多,存储硬件的成本就越高。如果我们是进行网络传输,则占用的带宽就更多,这时就会影响到系统的吞吐量。
|
||||
|
||||
Java序列化中使用了ObjectOutputStream来实现对象转二进制编码,那么这种序列化机制实现的二进制编码完成的二进制数组大小,相比于NIO中的ByteBuffer实现的二进制编码完成的数组大小,有没有区别呢?
|
||||
|
||||
我们可以通过一个简单的例子来验证下:
|
||||
|
||||
```
|
||||
User user = new User();
|
||||
user.setUserName("test");
|
||||
user.setPassword("test");
|
||||
|
||||
ByteArrayOutputStream os =new ByteArrayOutputStream();
|
||||
ObjectOutputStream out = new ObjectOutputStream(os);
|
||||
out.writeObject(user);
|
||||
|
||||
byte[] testByte = os.toByteArray();
|
||||
System.out.print("ObjectOutputStream 字节编码长度:" + testByte.length + "\n");
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
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("ByteBuffer 字节编码长度:" + bytes.length+ "\n");
|
||||
|
||||
|
||||
```
|
||||
|
||||
运行结果:
|
||||
|
||||
```
|
||||
ObjectOutputStream 字节编码长度:99
|
||||
ByteBuffer 字节编码长度:16
|
||||
|
||||
```
|
||||
|
||||
这里我们可以清楚地看到:Java序列化实现的二进制编码完成的二进制数组大小,比ByteBuffer实现的二进制编码完成的二进制数组大小要大上几倍。因此,Java序列后的流会变大,最终会影响到系统的吞吐量。
|
||||
|
||||
### 4.序列化性能太差
|
||||
|
||||
序列化的速度也是体现序列化性能的重要指标,如果序列化的速度慢,就会影响网络通信的效率,从而增加系统的响应时间。我们再来通过上面这个例子,来对比下Java序列化与NIO中的ByteBuffer编码的性能:
|
||||
|
||||
```
|
||||
User user = new User();
|
||||
user.setUserName("test");
|
||||
user.setPassword("test");
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
for(int i=0; i<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("ObjectOutputStream 序列化时间:" + (endTime - startTime) + "\n");
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
long startTime1 = System.currentTimeMillis();
|
||||
for(int i=0; i<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("ByteBuffer 序列化时间:" + (endTime1 - startTime1)+ "\n");
|
||||
|
||||
```
|
||||
|
||||
运行结果:
|
||||
|
||||
```
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
|
||||
@@ -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通信。
|
||||
|
||||
RPC(Remote Process Call),即远程服务调用,是通过网络请求远程计算机程序服务的通信技术。RPC框架封装好了底层网络通信、序列化等技术,我们只需要在项目中引入各个服务的接口包,就可以实现在代码中调用RPC服务同调用本地方法一样。正因为这种方便、透明的远程调用,RPC被广泛应用于当下企业级以及互联网项目中,是实现分布式系统的核心。
|
||||
|
||||
RMI(Remote Method Invocation)是JDK中最先实现了RPC通信的框架之一,RMI的实现对建立分布式Java应用程序至关重要,是Java体系非常重要的底层技术,很多开源的RPC通信框架也是基于RMI实现原理设计出来的,包括Dubbo框架中也接入了RMI框架。接下来我们就一起了解下RMI的实现原理,看看它存在哪些性能瓶颈有待优化。
|
||||
|
||||
## RMI:JDK自带的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通信协议吗?通过这讲的学习,你能对比谈谈各自的优缺点了吗?
|
||||
|
||||
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
|
||||
219
极客时间专栏/Java性能调优实战/模块二 · Java编程性能调优/11 | 答疑课堂:深入了解NIO的优化实现原理.md
Normal file
219
极客时间专栏/Java性能调优实战/模块二 · Java编程性能调优/11 | 答疑课堂:深入了解NIO的优化实现原理.md
Normal 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/O,Selector就是使用了这五种类型中的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设备->内核空间->用户空间->内核空间->其它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。
|
||||
|
||||
今天的内容比较多,看到这里不知道你消化得如何?如果还有疑问,请在留言区中提出,我们共同探讨。最后欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他加入讨论。
|
||||
|
||||
|
||||
122
极客时间专栏/Java性能调优实战/模块二 · Java编程性能调优/加餐 | 推荐几款常用的性能测试工具.md
Normal file
122
极客时间专栏/Java性能调优实战/模块二 · Java编程性能调优/加餐 | 推荐几款常用的性能测试工具.md
Normal 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>
|
||||
|
||||
你好,我是刘超。很多同学给我留言想让我讲讲工具,所以我的第一篇加餐就光速来了~
|
||||
|
||||
熟练掌握一款性能测试工具,是我们必备的一项技能。他不仅可以帮助我们模拟测试场景(包括并发、复杂的组合场景),还能将测试结果转化成数据或图形,帮助我们更直观地了解系统性能。
|
||||
|
||||
## 常用的性能测试工具
|
||||
|
||||
常用的性能测试工具有很多,在这里我将列举几个比较实用的。
|
||||
|
||||
对于开发人员来说,首选是一些开源免费的性能(压力)测试软件,例如ab(ApacheBench)、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&password=test&sex=1
|
||||
|
||||
```
|
||||
|
||||
**附上几个常用参数的含义:**
|
||||
|
||||
- -n:总请求次数(最小默认为1);
|
||||
- -c:并发次数(最小默认为1且不能大于总请求次数,例如:10个请求,10个并发,实际就是1人请求1次);
|
||||
- -p:post参数文档路径(-p和-T参数要配合使用);
|
||||
- -T:header头内容类型(此处切记是大写英文字母T)。
|
||||
|
||||
当我们测试一个get请求接口时,可以直接在链接的后面带上请求的参数:
|
||||
|
||||
```
|
||||
ab -c 10 -n 100 http://www.test.api.com/test/login?userName=test&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在性能压测时,表现得非常稳定和高效。相比JMeter,LoadRunner可以模拟出不同的内网IP地址,通过分配不同的IP地址给测试的用户,模拟真实环境下的用户。这里我就不展开详述了。
|
||||
|
||||
## 总结
|
||||
|
||||
三种常用的性能测试工具就介绍完了,最后我把今天的主要内容为你总结了一张图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/1a/a70d0081607081471df4db435641b51a.jpg" alt="">
|
||||
|
||||
现在测试工具非常多,包括阿里云的PTS测试工具也很好用,但每款测试工具其实都有自己的优缺点。个人建议,还是在熟练掌握其中一款测试工具的前提下,再去探索其他测试工具的使用方法会更好。
|
||||
|
||||
今天的加餐到这里就结束了,如果你有其他疑问或者更多想要了解的内容,欢迎留言告诉我。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user