admin管理员组

文章数量:1579083

目录

一、java编程性能优化实战

1、如何使用String.intern 节省内存

2、如何使用字符串的分割方法?

3、ArrayList还是LinkedList?使用不当性能差千倍

4、Stream 如何提高遍历集合效率?

5、使用ProtoBuf序列化替换java序列化

6、网络通信优化之通信协议: 如何优化rpc网络通信

7、常用的性能测试工具

 


一、java编程性能优化实战

1、如何使用String.intern 节省内存

Twitter  每次发布消息状态的时候,都会产生一个地址信息,以当时Twitter用户的规模预估,服务器需要32G的内存来存储地址消息。

具体做法就是,在每次赋值的时候使用String的intern方法,如果常量池中有相同值,就会重复使用该对象,返回对象的引用,这样一开始的对象就可以被回收掉。这种方式可以使重复性非常高的地址信息存储从20G降到几百兆。

SharedLocation sharedLocation = new SharedLocation();
sharedLocation.setCity(messageInfo.getCity().intern()); 
sharedLocation.setCount(messageInfo.getCount().inter());
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.println("a==b")
}

结果会输出  a==b

在字符串常量中,默认会将对象放入常量池,在字符串变量中,对象是会创建在堆内存中,同时也会在常量池中创建一个字符串对象,复制到堆内存对象中,并返回堆内存对象引用。

如果调用 intern() 方法,会去查看字符串常量池中是否有等于该对象的字符串,如果没有,就在常量池中新增该对象,并返回该对象的引用,如果有,就返回从常量池当中的字符串引用,堆内存中原有的对象由于没有引用指向它,将会通过垃圾回收器回收。

了解了原理,我们再看一下上面的例子。

在一开始创建a变量时,会在堆内存中创建一个对象,同时会在加载类的时候,在常量池中创建一个字符串对象,在调用intern()方法之后,会去常量池中查找是否有等于该字符串的对象,有就返回引用。

在创建b变量时,会在堆内存也创建一个对象,此时常量池中有该字符串对象,就不再创建,调用intern()方法则会去常量池中判断是否有等于该字符串的对象,发现有等于“abc”字符串的对象,就直接返回引用,而在堆内存中的对象,由于没有引用指向它,将会被垃圾回收。所以a和b引用的是同一个对象。

下面我用一张图来总结下String字符串的创建分配内存地址情况:

使用intern方法需要注意的一点是,一定要结合实际场景。因为常量池的实现是类似于一个hashtable的实现方式,hashtable存储的数据越大,遍历的时间复杂度就会增加,如果数据过大,会增加整个字符串常量池的负担。

2、如何使用字符串的分割方法?

Split() 的方法使用了正则表达式实现了其强大的分割功能,而正则表达式的性能是非常不稳定的,使用不恰当会引起回溯问题,很可能导致cpu居高不下。

所以我们应该慎重使用split()方法,我们可以用String.indexOf() 方法代替split() 方法完成字符串的分割。如果实现无法满足需求,你就在使用Split()方法的时候,对回溯问题加以重视就可以了。

什么是正则表达式?

构造正则表达式语法的元字符,由普通字符,标准字符,限定字符,定位字符组成。

如果避免回溯问题?

NFA自动机的贪婪特性就是导火索,这和正则表达式的匹配模式息息相关,一起来了解下。

2.1 贪婪模式 (Greedy)

顾名思义,就是在数量匹配中,如果单独使用 +、 ? 、* 或{min,max} 等量词,正则表达式
会匹配尽可能多的内容。
例如,上边那个例子:
text=“abbc”
regex=“ab{1,3}c”
就是在贪婪模式下,NFA 自动机读取了最大的匹配范围,即匹配 3 个 b 字符。匹配发生了
一次失败,就引起了一次回溯。如果匹配结果是“abbbc”,就会匹配成功。
text=“abbbc”
regex=“ab{1,3}c”

2.2 懒惰模式(Reluctant)
在该模式下,正则表达式会尽可能少地重复匹配字符。如果匹配成功,它会继续匹配剩余的
字符串。
例如,在上面例子的字符后面加一个“?”,就可以开启懒惰模式。
text=“abc”
regex=“ab{1,3}?c”
匹配结果是“abc”,该模式下 NFA 自动机首先选择最小的匹配范围,即匹配 1 个 b 字
符,因此就避免了回溯问题。

2.3. 独占模式(Possessive)
同贪婪模式一样,独占模式一样会最大限度地匹配更多内容;不同的是,在独占模式下,匹
配失败就会结束匹配,不会发生回溯问题。
还是上边的例子,在字符后面加一个“+”,就可以开启独占模式。
text=“abbc”
regex=“ab{1,3}+bc”
结果是不匹配,结束匹配,不会发生回溯问题。讲到这里,你应该非常清楚了,避免回溯的
方法就是:使用懒惰模式和独占模式
还有开头那道“一个 split() 方法为什么会影响到 TPS”的存疑,你应该也清楚了吧?
我使用了 split() 方法提取域名,并检查请求参数是否符合规定。split() 在匹配分组时遇到
特殊字符产生了大量回溯,我当时是在正则表达式后加了一个需要匹配的字符和“+”,解
决了这个问题。


 \\?(([A-Za-z0-9-~_=%]++\\&{0,1})+)

正则表达式的优化
正则表达式带来的性能问题,给我敲了个警钟,在这里我也希望分享给你一些心得。任何一
个细节问题,都有可能导致性能问题,而这背后折射出来的是我们对这项技术的了解不够透彻。

下面我就总结几种
正则表达式的优化方法给你。
2.4 少用贪婪模式,多用独占模式
贪婪模式会引起回溯问题,我们可以使用独占模式来避免回溯。前面详解过了,这里我就不
再解释了。
2.5 减少分支选择
分支选择类型“(X|Y|Z)”的正则表达式会降低性能,我们在开发的时候要尽量减少使用。
如果一定要用,我们可以通过以下几种方式来优化:
首先,我们需要考虑选择的顺序,将比较常用的选择项放在前面,使它们可以较快地被匹
配;
其次,我们可以尝试提取共用模式,例如,将“(abcd|abef)”替换为“ab(cd|ef)”,后者
匹配速度较快,因为 NFA 自动机会尝试匹配 ab,如果没有找到,就不会再尝试任何选
项;
最后,如果是简单的分支选择类型,我们可以用三次 index 代替“(X|Y|Z)”,如果测试的
话,你就会发现三次 index 的效率要比“(X|Y|Z)”高出一些


2.6 减少捕获嵌套
在讲这个方法之前,我先简单介绍下什么是捕获组和非捕获组。
捕获组是指把正则表达式中,子表达式匹配的内容保存到以数字编号或显式命名的数组中,
方便后面引用。一般一个 () 就是一个捕获组,捕获组可以进行嵌套。
非捕获组则是指参与匹配却不进行分组编号的捕获组,其表达式一般由(?:exp)组成。
在正则表达式中,每个捕获组都有一个编号,编号 0 代表整个匹配到的内容。我们可以看
下面的例子:

public static void main( String[] args )
{
String text = "<input high=\"20\" weight=\"70\">test</input>";
String reg="(<input.*?>)(.*?)(</input>)";
Pattern p = Patternpile(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 = Patternpile(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

综上可知:减少不需要获取的分组,可以提高正则表达式的性能。
总结
正则表达式虽然小,却有着强大的匹配功能。我们经常用到它,比如,注册页面手机号或邮
箱的校验。
但很多时候,我们又会因为它小而忽略它的使用规则,测试用例中又没有覆盖到一些特殊用
例,不乏上线就中招的情况发生。
综合我以往的经验来看,如果使用正则表达式能使你的代码简洁方便,那么在做好性能排查
的前提下,可以去使用;如果不能,那么正则表达式能不用就不用,以此避免造成更多的性
能问题.

3、ArrayList还是LinkedList?使用不当性能差千倍

ArrayList 很常用,先来几道测试题,自检下你对 ArrayList 的了解程度。
问题 1:我们在查看 ArrayList 的实现类源码时,你会发现对象数组 elementData 使用了
transient 修饰,我们知道 transient 关键字修饰该属性,则表示该属性不会被序列化,然
而我们并没有看到文档中说明 ArrayList 不能被序列化,这是为什么?
问题 2:我们在使用 ArrayList 进行新增、删除时,经常被提醒“使用 ArrayList 做新增删
除操作会影响效率”。那是不是 ArrayList 在大量新增元素的场景下效率就一定会变慢呢?
问题 3:如果让你使用 for 循环以及迭代循环遍历一个 ArrayList,你会使用哪种方式呢?
原因是什么?
如果你对这几道测试都没有一个全面的了解,那就跟我一起从数据结构、实现原理以及源码
角度重新认识下 ArrayList 吧。


3.1 ArrayList 实现类
ArrayList 实现了 List 接口,继承了 AbstractList 抽象类,底层是数组实现的,并且实现了
自增扩容数组大小。
ArrayList 还实现了 Cloneable 接口和 Serializable 接口,所以他可以实现克隆和序列化。
ArrayList 还实现了 RandomAccess 接口。你可能对这个接口比较陌生,不知道具体的用
处。通过代码我们可以发现,这个接口其实是一个空接口,什么也没有实现,那 ArrayList
为什么要去实现它呢?
其实 RandomAccess 接口是一个标志接口,他标志着“只要实现该接口的 List 类,都能
实现快速随机访问”。

3.2ArrayList 属性
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.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;
}

3.4ArrayList 新增元素
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 集合
的性能要好.

3.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;
}

3.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 方式最重要的性能优化是链头和链尾的插入删除操作更加快捷了。
3.7 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

3.8LinkedList 属性

我们前面讲到了 LinkedList 的两个重要属性 first/last 属性,其实还有一个 size 属性。我
们可以看到这三个属性都被 transient 修饰了,原因很简单,我们在序列化的时候不会只对
头尾进行序列化,所以 LinkedList 也是自行实现 readObject 和 writeObject 进行序列化

与反序列化
transient int size = 0;
transient Node<E> first;
transient Node<E> last;

3.9LinkedList 新增元素

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++;
}

3.10 LinkedList 删除元素

在 LinkedList 删除元素的操作中,我们首先要通过循环找到要删除的元素,如果要删除的
位置处于 List 的前半段,就从前往后找;若其位置处于后半段,就从后往前找。
这样做的话,无论要删除较为靠前或较为靠后的元素都是非常高效的,但如果 List 拥有大
量元素,移除的元素又在 List 的中间段,那效率相对来说会很低

3.11LinkedList 遍历元素

LinkedList 的获取元素操作实现跟 LinkedList 的删除元素操作基本类似,通过分前后半段
来循环查找到对应的元素。但是通过这种方式来查询元素是非常低效的,特别是在 for 循环
遍历的情况下,每一次循环都会去遍历半个 List。
所以在 LinkedList 循环遍历时,我们可以使用 iterator 方式迭代循环,直接拿到我们的元
素,而不需要通过循环查找 List。
总结
前面我们已经从源码的实现角度深入了解了 ArrayList 和 LinkedList 的实现原理以及各自
的特点。如果你能充分理解这些内容,很多实际应用中的相关性能问题也就迎刃而解了。
就像如果现在还有人跟你说,“ArrayList 和 LinkedList 在新增、删除元素时,LinkedList
的效率要高于 ArrayList,而在遍历的时候,ArrayList 的效率要高于 LinkedList”,你还会
表示赞同吗?
现在我们不妨通过几组测试来验证一下。这里因为篇幅限制,所以我就直接给出测试结果
了。

3.12 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 的效率也会降低。
 

3.13 ArrayList 和 LinkedList 删除元素操作测试

从集合头部位置删除元素
从集合中间位置删除元素
从集合尾部位置删除元素
测试结果 (花费时间):

ArrayList>LinkedList
ArrayList<LinkedList
ArrayList<LinkedList
ArrayList 和 LinkedList 删除元素操作测试的结果和添加元素操作测试的结果很接近,这是
一样的原理,我在这里就不重复讲解了。

3.14 ArrayList 和 LinkedList 遍历元素操作测试

for(;;) 循环
迭代器迭代循环

测试结果 (花费时间):
ArrayList<LinkedList
ArrayList≈LinkedList
我们可以看到,LinkedList 的 for 循环性能是最差的,而 ArrayList 的 for 循环性能是最好
的。
这是因为 LinkedList 基于链表实现的,在使用 for 循环的时候,每一次 for 循环都会去遍
历半个 List,所以严重影响了遍历的效率;ArrayList 则是基于数组实现的,并且实现了
RandomAccess 接口标志,意味着 ArrayList 可以实现快速随机访问,所以 for 循环效率
非常高。
LinkedList 的迭代循环遍历和 ArrayList 的迭代循环遍历性能相当,也不会太差,所以在遍
历 LinkedList 时,我们要切忌使用 for 循环遍历
 

4、Stream 如何提高遍历集合效率?

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 不仅可
以通过串行的方式实现数据操作,还可以通过并行的方式处理大批量数据,提高数据的处理
效率。
4.1Stream 操作分类

官方将 Stream 中的操作分为两大类:中间操作(Intermediate operations)和终结操作
(Terminal operations)。中间操作只对操作进行了记录,即只会返回一个流,不会进行
计算操作,而终结操作是实现了计算操作。
中间操作又可以分为无状态(Stateless)与有状态(Stateful)操作,前者是指元素的处理
不受之前元素的影响,后者是指该操作只有拿到所有元素之后才能继续下去。

终结操作又可以分为短路(Short-circuiting)与非短路(Unshort-circuiting)操作,前
者是指遇到某些符合条件的元素就可以得到最终结果,后者是指必须处理完所有元素才能得
到最终结果。操作分类详情如下图所示:

我们通常还会将中间操作称为懒操作,也正是由这种懒操作结合终结操作、数据源构成的处
理管道(Pipeline),实现了 Stream 的高效。

4.2 Stream 源码实现
在了解 Stream 如何工作之前,我们先来了解下 Stream 包是由哪些主要结构类组合而成
的,各个类的职责是什么。参照下图:

BaseStream 和 Stream 为最顶端的接口类。BaseStream 主要定义了流的基本接口方法,
例如,spliterator、isParallel 等;Stream 则定义了一些流的常用操作方法,例如,
map、filter 等。
ReferencePipeline 是一个结构类,他通过定义内部类组装了各种操作流。他定义了
Head、StatelessOp、StatefulOp 三个内部类,实现了 BaseStream 与 Stream 的接口方
法。
Sink 接口是定义每个 Stream 操作之间关系的协议,他包含 begin()、end()、
cancellationRequested()、accpt() 四个方法。ReferencePipeline 最终会将整个 Stream
流操作组装成一个调用链,而这条调用链上的各个 Stream 操作的上下关系就是通过 Sink
接口协议来定义实现的。

4.3 Stream 操作叠加

我们知道,一个 Stream 的各个操作是由处理管道组装,并统一完成数据处理的。在 JDK
中每次的中断操作会以使用阶段(Stage)命名。
管道结构通常是由 ReferencePipeline 类实现的,前面讲解 Stream 包结构时,我提到过
ReferencePipeline 包含了 Head、StatelessOp、StatefulOp 三种内部类
Head 类主要用来定义数据源操作,在我们初次调用 names.stream() 方法时,会初次加载
Head 对象,此时为加载数据源操作;接着加载的是中间操作,分别为无状态中间操作
StatelessOp 对象和有状态操作 StatefulOp 对象,此时的 Stage 并没有执行,而是通过
AbstractPipeline 生成了一个中间操作 Stage 链表;当我们调用终结操作时,会生成一个
最终的 Stage,通过这个 Stage 触发之前的中间操作,从最后一个 Stage 开始,递归产生
一个 Sink 链。如下图所示:

下面我们再通过一个例子来感受下 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(spliterato
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;
thisbinedFlags = StreamOpFlagbineOpFlags(opFlags, previousStagebined
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
sink = p.opWrapSink(p.previousStagebinedFlags, 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.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.getOpFla
: terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOp
}

这里的并行处理指的是,Stream 结合了 ForkJoin 框架,对 Stream 处理进行了分片,
Splititerator 中的 estimateSize 方法会估算出分片的数据量。
ForkJoin 框架和估算算法,在这里我就不具体讲解了,如果感兴趣,你可以深入源码分析
下该算法的实现。
通过预估的数据量获取最小处理单元的阀值,如果当前分片大小大于最小处理单元的阀值,
就继续切分集合。每个分片将会生成一个 Sink 链表,当所有的分片操作完成后,ForkJoin
框架将会合并分片任何结果集。

4.5 合理使用 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上查看。通过以上测试,我统计出的测试结果如下(迭代使用时间):


常规的迭代 <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 的使用场景。

5、使用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 等语言的所有基础数据
类型。不同的编码方式对应不同的数据类型,还能采用不同的存储格式。如下图所示:

对于存储 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 是比较有特点的,而且性能以及安全方
面都得到了业界的认可,我们可以结合自身业务来选择一种适合的序列化框架,来优化系统
的序列化性能。


6、网络通信优化之通信协议: 如何优化rpc网络通信

RPC 通信是大型服务框架的核心
我们经常讨论微服务,首要应该了解的就是微服务的核心到底是什么,这样我们在做技术选
型时,才能更准确地把握需求。
就我个人理解,我认为微服务的核心是远程通信和服务治理。远程通信提供了服务之间通信
的桥梁,服务治理则提供了服务的后勤保障。所以,我们在做技术选型时,更多要考虑的是
这两个核心的需求。
我们知道服务的拆分增加了通信的成本,特别是在一些抢购或者促销的业务场景中,如果服
务之间存在方法调用,比如,抢购成功之后需要调用订单系统、支付系统、券包系统等,这
种远程通信就很容易成为系统的瓶颈。所以,在满足一定的服务治理需求的前提下,对远程
通信的性能需求就是技术选型的主要影响因素。
目前,很多微服务框架中的服务通信是基于 RPC 通信实现的,在没有进行组件扩展的前提
下,SpringCloud 是基于 Feign 组件实现的 RPC 通信(基于 Http+Json 序列化实现),
Dubbo 是基于 SPI 扩展了很多 RPC 通信框架,包括 RMI、Dubbo、Hessian 等 RPC 通
信框架(默认是 Dubbo+Hessian 序列化)。不同的业务场景下,RPC 通信的选择和优化
标准也不同。
例如,开头我提到的我们部门在选择微服务框架时,选择了 Dubbo。当时的选择标准就是
RPC 通信可以支持抢购类的高并发,在这个业务场景中,请求的特点是瞬时高峰、请求量
大和传入、传出参数数据包较小。而 Dubbo 中的 Dubbo 协议就很好地支持了这个请求。
无论从响应时间还是吞吐量上来看,单一 TCP 长连接
+Protobuf 序列化实现的 RPC 通信框架都有着非常明显的优势。

什么是 RPC 通信
一提到 RPC,你是否还想到 MVC、SOA 这些概念呢?如果你没有经历过这些架构的演
变,这些概念就很容易混淆。你可以通过下面这张图来了解下这些架构的演变史。

无论是微服务、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 的通信过程:

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 通信包括了建立通信、实现报文、传输协议以及传输数据编解码等操作,接下来我们
就从每一层的优化出发,逐步实现整体的性能优化。
6.1. 选择合适的通信协议
要实现不同机器间的网络通信,我们先要了解计算机系统网络通信的基本原理。网络通信是
两台设备之间实现数据流交换的过程,是基于网络传输协议和传输数据的编解码来实现的。
其中网络传输协议有 TCP、UDP 协议,这两个协议都是基于 Socket 编程接口之上,为某
类应用场景而扩展出的传输协议。通过以下两张图,我们可以大概了解到基于 TCP 和 UDP
协议实现的 Socket 网络通信是怎样的一个流程。

基于 TCP 协议实现的 Socket 通信是有连接的,而传输数据是要通过三次握手来实现数据
传输的可靠性,且传输数据是没有边界的,采用的是字节流模式。
基于 UDP 协议实现的 Socket 通信,客户端不需要建立连接,只需要创建一个套接字发送
数据报给服务端,这样就不能保证数据报一定会达到服务端,所以在传输数据方面,基于
UDP 协议实现的 Socket 通信具有不可靠性。UDP 发送的数据采用的是数据报模式,每个
UDP 的数据报都有一个长度,该长度将与数据一起发送到服务端。
通过对比,我们可以得出优化方法:为了保证数据传输的可靠性,通常情况下我们会采用
TCP 协议。如果在局域网且对数据传输的可靠性没有要求的情况下,我们也可以考虑使用
UDP 协议,毕竟这种协议的效率要比 TCP 协议高。
6.2. 使用单一长连接
如果是基于 TCP 协议实现 Socket 通信,我们还能做哪些优化呢?
服务之间的通信不同于客户端与服务端之间的通信。客户端与服务端由于客户端数量多,基
于短连接实现请求可以避免长时间地占用连接,导致系统资源浪费。
但服务之间的通信,连接的消费端不会像客户端那么多,但消费端向服务端请求的数量却一
样多,我们基于长连接实现,就可以省去大量的 TCP 建立和关闭连接的操作,从而减少系
统的性能消耗,节省时间。
6.3.优化 Socket 通信
建立两台机器的网络通信,我们一般使用 Java 的 Socket 编程实现一个 TCP 连接。传统的
Socket 通信主要存在 I/O 阻塞、线程模型缺陷以及内存拷贝等问题。我们可以使用比较成
熟的通信框架,比如 Netty。Netty4 对 Socket 通信编程做了很多方面的优化,具体见下
方。

高效的 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:当设置该选项以后,连接会检查长时间没有发送数据的客户端的连接状
态,检测到客户端断开连接后,服务端将回收该连接。我们可以将该时间设置得短一些,来
提高回收连接的效率。
6.4量身定做报文格式
接下来就是实现报文,我们需要设计一套报文,用于描述具体的校验、操作、传输数据等内
容。为了提高传输的效率,我们可以根据自己的业务和架构来考虑设计,尽量实现报体小、
满足功能、易解析等特性。我们可以参考下面的数据格式:

6.5编码、解码
在 09 讲中,我们分析过序列化编码和解码的过程,对于实现一个好的网络通信协议来说,
兼容优秀的序列化框架是非常重要的。如果只是单纯的数据对象传输,我们可以选择性能相
对较好的 Protobuf 序列化,有利于提高网络通信的性能

6.6 调整 Linux 的 TCP 参数设置选项
如果 RPC 是基于 TCP 短连接实现的,我们可以通过修改 Linux TCP 配置项来优化网络通
信。开始 TCP 配置项的优化之前,我们先来了解下建立 TCP 连接的三次握手和关闭 TCP
连接的四次握手,这样有助后面内容的理解。
我们可以通过 sysctl -a | grep net.xxx 命令运行查看 Linux 系统默认的的 TCP 参数设置,
如果需要修改某项配置,可以通过编辑 vim/etc/sysctl.conf,加入需要修改的配置项, 并
通过 sysctl -p 命令运行生效修改后的配置项设置。通常我们会通过修改以下几个配置项来
提高网络吞吐量和降低延时。

以上就是我们从不同层次对 RPC 优化的详解,除了最后的 Linux 系统中 TCP 的配置项设
置调优,其它的调优更多是从代码编程优化的角度出发,最终实现了一套 RPC 通信框架的
优化路径。
弄懂了这些,你就可以根据自己的业务场景去做技术选型了,还能很好地解决过程中出现的
一些性能问题。
总结
在现在的分布式系统中,特别是系统走向微服务化的今天,服务间的通信就显得尤为频繁,
掌握服务间的通信原理和通信协议优化,是你的一项的必备技能。
在一些并发场景比较多的系统中,我更偏向使用 Dubbo 实现的这一套 RPC 通信协议。
Dubbo 协议是建立的单一长连接通信,网络 I/O 为 NIO 非阻塞读写操作,更兼容了
Kryo、FST、Protobuf 等性能出众的序列化框架,在高并发、小对象传输的业务场景中非
常实用。
在企业级系统中,业务往往要比普通的互联网产品复杂,服务与服务之间可能不仅仅是数据
传输,还有图片以及文件的传输,所以 RPC 的通信协议设计考虑更多是功能性需求,在性
能方面不追求极致。其它通信框架在功能性、生态以及易用、易入门等方面更具有优势

7、常用的性能测试工具


常用的性能测试工具有很多,在这里我将列举几个比较实用的。
对于开发人员来说,首选是一些开源免费的性能(压力)测试软件,例如
ab(ApacheBench)、JMeter 等;对于专业的测试团队来说,付费版的 LoadRunner 是
首选。当然,也有很多公司是自行开发了一套量身定做的性能测试软件,优点是定制化强,
缺点则是通用性差。


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

本文标签: 性能实战Java