《Java性能调优》阅读总结


怎么做好性能调优

1. 扎实的计算机基础

我们调优的对象不是单一的应用服务,而是错综复杂的系统。应用服务的性能可能与操作系统、网络、数据库等组件相关,所以我们需要储备计算机组成原理、操作系统、网络协议以及数据库等基础知识。具体的性能问题往往还与传输、计算、存储数据等相关,那我们还需要储备数据结构、算法以及数学等基础知识。

2. 习惯透过源码了解技术本质

我身边有很多好学的同学,他们经常和我分享在一些技术论坛或者公众号上学到的技术。这个方式很好,因为论坛上边的大部分内容,都是生产者自己吸收消化后总结的知识点,能帮助我们快速获取、快速理解。但是只做到这个程度还不够,因为你缺失了自己的判断。怎么办呢?我们需要深入源码,通过分析来学习、总结一项技术的实现原理和优缺点,这样我们就能更客观地去学习一项技术,还能透过源码来学习牛人的思维方式,收获更好的编码实现方式。

3. 善于追问和总结

很多同学在使用一项技术时,只是因为这项技术好用就用了,从来不问自己:为什么这项技术可以提升系统性能?对比其他技术它好在哪儿?实现的原理又是什么呢?事实上,“知其然且知所以然”才是我们积累经验的关键。知道了一项技术背后的实现原理,我们才能在遇到性能问题时,做到触类旁通。

为什么要做系统调优

有的人,就是可以做到既减少服务器的数量,还能提升系统的性能

好的系统性能调优不仅仅可以提高系统的性能,还能为公司节省资源

什么时候介入调优

在项目初期,不必要做细致的调优工作,我们要做的是从编码上减少磁盘 I/O 操作、降低竞争锁的使用以及使用高效的算法等等。遇到比较复杂的业务,我们可以充分利用设计模式来优化业务代码。例如,设计商品价格的时候,往往会有很多折扣活动、红包活动,我们可以用装饰模式去设计这个业务。

影响性能的几个可能出现瓶颈的地方

性能指标体现

计算机资源分配使用率

通常由 CPU 占用率、内存使用率、磁盘 I/O、网络 I/O 来表示资源使用率。这几个参数好比一个木桶,如果其中任何一块木板出现短板,任何一项分配不合理,对整个系统性能的影响都是毁灭性的。

负载承受能力

当系统压力上升时,你可以观察,系统响应时间的上升曲线是否平缓。这项指标能直观地反馈给你,系统所能承受的负载压力极限。例如,当你对系统进行压测时,系统的响应时间会随着系统并发数的增加而延长,直到系统无法处理这么多请求,抛出大量错误时,就到了极限。

QA

测试性能方法策略

测试需要注意的问题

优化策略

兜底策略

无论优化的有多好,还是会存在承受极限,为了保证系统的稳定性,我们还需要采用一些兜底策略。

字符串优化

char数组的方式存在什么问题?可能会出现内存泄漏

为什么char——>byte? char 字符占 16 位,2 个字节。这个情况下,存储单字节编码内的字符(占一个字节的字符)就显得非常浪费。

coder 属性默认有 0 和 1 两个值,0 代表 Latin-1(单字节编码),1 代表 UTF-16。如果 String 判断字符串只包含了 Latin-1,则 coder 属性值为 0,反之则为 1。

为什么char[] 被 final+private 修饰?三点:

question:对一个 String 对象 str 赋值“hello”,然后又让 str 值为“world”,这个时候 str 的值变成了“world”。为什么还说 String 对象不可变呢?

answer:第一次赋值的时候,创建了一个“hello”对象,str 引用指向“hello”地址;第二次赋值的时候,又重新创建了一个对象“world”,str 引用指向了“world”,但“hello”对象依然存在于内存中。

也就是说 str 并不是对象,而只是一个对象引用。真正的对象依然还在内存中,没有被改变。

编译器优化

源代码:

String str = "abcdef";
 
for(int i=0; i<1000; i++) {
      str = str + i;
}

优化后代码:

String str = "abcdef";
 
for(int i=0; i<1000; i++) {
        	  str = (new StringBuilder(String.valueOf(str))).append(i).toString();
}

能得出什么?即使使用 + 号作为字符串的拼接,也一样可以被编译器优化成 StringBuilder 的方式。平时做字符串拼接的时候,还是要显示地使用 String Builder 来提升系统性能。

如何使用 String.intern 节省内存?

来看个实例,twitter的用户发推的地理位置信息

public class Location {
    private String city;
    private String region;
    private String countryCode;
    private double longitude;
    private double latitude;
} 

这样存的话需要约32G的内存,抽取一部分公共信息出来

public class SharedLocation {
 
	private String city;
	private String region;
	private String countryCode;
}
 
public class Location {
 
	private SharedLocation sharedLocation;
	double longitude;
	double latitude;
}

这样存储量可以减少至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());

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

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

字符串的分割

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

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

QA

Q:String.substring 方法也不再共享 char[],从而解决了使用该方法可能导致的内存泄漏问题。这句话怎么理解

A: 在Java6中substring方法会调用new string构造函数,此时会复用原来的char数组,而如果我们仅仅是用substring获取一小段字符,而原本string字符串非常大的情况下,substring的对象如果一直被引用,由于substring的里面的char数组仍然指向原字符串,此时string字符串也无法回收,从而导致内存泄露。

试想下,如果有大量这种通过substring获取超大字符串中一小段字符串的操作,会因为内存泄露而导致内存溢出。

Q:使用intern的方式怎么把握这个度?

A: 如果对空间要求高于时间要求,且存在大量重复字符串时,可以考虑使用常量池存储。

如果对查询速度要求很高,且存储字符串数量很大,重复率很低的情况下,不建议存储在常量池中。

Q:A.String str= “abcdef”; B.String str= new String(“abcdef”); C.String str= new String(“abcdef”). intern(); D.String str1=str.intern(); 这个方式到底什么时候用?

A:实际编码中,我们要结合实际场景来选择创建字符串的方式,例如,在创建局部变量以及常量时,我们一般使用A的这种方式;如果我们要区别一个字符串创建两个不同的对象来使用时,会选择B;intern一般使用的比较少,例如我们平时会创建很多一样的字符串的对象时,且对象会保存在内存中,我们可以考虑使用intern方法来减少过多重复对象占用内存空间。

注意正则表达式回溯带来的性能问题

背景:

​ 产品有个通过正则表达式验证用户输入电话号码是否合法的功能(没有约束输入号码的长度),研发人员写的正在表达式(java代码):regexp=”^[+]?(\d+)((-?\s?)\d+)*$”,被别人测出来存在正则表达式回溯的漏洞,即输入很长一段字符,触发正则回溯后,导致CPU占用达到200%。搜了下相关资料,梳理下这个漏洞的发生原因如下。

1. 正则表达式引擎

说起回溯陷阱,要先从正则表达式的引擎说起。正则引擎主要可以分为基本不同的两大类:一种是DFA(确定型有穷自动机),另一种是NFA(不确定型有穷自动机)。简单来讲,NFA 对应的是正则表达式主导的匹配,而 DFA 对应的是文本主导的匹配。

DFA从匹配文本入手,从左到右,每个字符不会匹配两次,它的时间复杂度是多项式的,所以通常情况下,它的速度更快,但支持的特性很少,不支持捕获组、各种引用等等;而NFA则是从正则表达式入手,不断读入字符,尝试是否匹配当前正则,不匹配则吐出字符重新尝试,通常它的速度比较慢,最优时间复杂度为多项式的,最差情况为指数级的。但NFA支持更多的特性,因而绝大多数编程场景下(包括java,js),我们面对的是NFA。以下面的表达式和文本为例,

  1. text = ‘after tonight’ regex = ‘to(nitenightanight)’

在NFA匹配时候,是根据正则表达式来匹配文本的,从t开始匹配a,失败,继续,直到文本里面的第一个t,接着比较o和e,失败,正则回退到 t,继续,直到文本里面的第二个t,然后 o和文本里面的o也匹配,继续,正则表达式后面有三个可选条件,依次匹配,第一个失败,接着二、三,直到匹配。

而在DFA匹配时候,采用的是用文本来匹配正则表达式的方式,从a开始匹配t,直到第一个t跟正则的t匹配,但e跟o匹配失败,继续,直到文本里面的第二个 t 匹配正则的t,接着o与o匹配,n的时候发现正则里面有三个可选匹配,开始并行匹配,直到文本中的g使得第一个可选条件不匹配,继续,直到最后匹配。

可以看到,DFA匹配过程中文本中的字符每一个只比较了一次,没有吐出的操作,应该是快于NFA的。另外,不管正则表达式怎么写,对于DFA而言,文本的匹配过程是一致的,都是对文本的字符依次从左到右进行匹配,所以,DFA在匹配过程中是跟正则表达式无关的,而 NFA 对于不同但效果相同的正则表达式,匹配过程是完全不同的。

2. 回溯

说完了引擎,我们来看看什么是回溯。先来看个例子:

Round 1

假设有正则表达式 /^(a*)b$/ 和字符串 aaaaab。如果用该正则匹配这个字符串会得到什么呢?

答案很简单。两者匹配,且捕获组捕获到字符串 aaaaa

Round 2

这次让我们把正则改写成 /^(a*)ab$/。再次和字符串 aaaaab 匹配。结果如何呢?

两者依然匹配,但捕获组捕获到字符串 aaaa。因为捕获组后续的表达式占用了 1 个 a 字符。但是你有没有考虑过这个看似简单结果是经过何种过程得到的呢?

让我们一步一步来看:

  1. 匹配开始 (a*) 捕获尽可能多的字符 a
  2. (a*) 一直捕获,直到遇到字符 b。这时 (a*) 已经捕获了 aaaaa
  3. 正则表达式继续执行 (a*) 之后的 ab 匹配。但此时由于字符串仅剩一个 b 字符。导致无法完成匹配。
  4. (a*) 从已捕获的字符串中“吐”出一个字符 a。这时捕获结果为 aaaa,剩余字符串为 ab
  5. 重新执行正则中 ab的匹配。发现正好与剩余字符串匹配。整个匹配过程结束。返回捕获结果 aaaa

从第3,4步可以看到,暂时的无法匹配并不会立即导致整体匹配失败。而是会从捕获组中“吐出”字符以尝试。这个“吐出”的过程就叫回溯。

回溯并不仅执行一次,而是会一直回溯到另一个极端。对于 * 符号而言,就是匹配 0 次的情况。

Round 3

这次我们把正则改为 /^(a*)aaaab$/。字符串依然为 aaaaab。根据前边的介绍很容易直到。此次要回溯 4 次才可以完成匹配。具体执行过程不再赘述。

悲观回溯

了解了回溯的工作原理,再来看悲观回溯就很容易理解了。

Round 4

这次我们的正则改为 /^(a*)b$/。但是把要匹配的字符串改为 aaaaa。去掉了结尾的字符 b

让我们看看此时的执行流程:

  1. (a*) 首先匹配了所有 aaaaa
  2. 尝试匹配 b。但是匹配失败。
  3. 回溯 1 个字符。此时剩余字符串为 a。依然无法匹配字符 b
  4. 回溯一直进行。直到匹配 0 次的情况。此时剩余字符串为 aaaaa。依然无法匹配 b
  5. 所有的可能性均已尝试过,依然无法匹配。最终导致整体匹配失败。

可以看到,虽然我们可以一眼看出二者无法匹配。但正则表达式在执行时还要“傻傻的”逐一回溯所有可能性,才能确定最终结果。这个“傻傻的”回溯过程就叫悲观回溯。

3. 贪婪、懒惰与独占

我们再来看一下究竟什么是贪婪模式。

下面的几个特殊字符相信大家都知道它们的用法:

i. ?: 告诉引擎匹配前导字符0次或一次。事实上是表示前导字符是可选的。 ii. +: 告诉引擎匹配前导字符1次或多次。 iii. *: 告诉引擎匹配前导字符0次或多次。 iv. {min, max}: 告诉引擎匹配前导字符min次到max次。min和max都是非负整数。如果有逗号而max被省略了,则表示max没有限制;如果逗号和max都被省略了,则表示重复min次。

默认情况下,这个几个特殊字符都是贪婪的,也就是说,它会根据前导字符去匹配尽可能多的内容。这也就解释了为什么在第3部分的例子中,第3步以后的事情会发生了。

在以上字符后加上一个问号(?)则可以开启懒惰模式,在该模式下,正则引擎尽可能少的重复匹配字符,匹配成功之后它会继续匹配剩余的字符串。在上例中,如果将正则换为

  1. ab{1,3}?c

则匹配过程变成了下面这样(橙色为匹配,黄色为不匹配),

img

由此可见,在非贪婪模式下,第2步正则中的b{1,3}?与文本b匹配之后,接着去用c与文本中的c进行匹配,而未发生回溯。

如果在以上四种表达式后加上一个加号(+),则会开启独占模式。同贪婪模式一样,独占模式一样会匹配最长。不过在独占模式下,正则表达式尽可能长地去匹配字符串,一旦匹配不成功就会结束匹配而不会回溯。我们以下面的表达式为例,

  1. ab{1,3}+bc

如果我们用文本”abbc”去匹配上面的表达式,匹配的过程如下图所示(橙色为匹配,黄色为不匹配),

img

可以发现,在第2和第3步,b{1,3}+会将文本中的2个字母b都匹配上,结果文本中只剩下一个字母c。那么在第4步时,正则中的b和文本中的c进行匹配,当无法匹配时,并不进行回溯,这时候整个文本就无法和正则表达式发生匹配。如果将正则表达式中的加号(+)去掉,那么这个文本整体就是匹配的了。

把以上三种模式的表达式列出如下,

贪婪懒惰独占
X?X??X?+
X*X*?X*+
X+X+?X++
X{n}X{n}?X{n}+
X{n,}X{n,}?X{n,}+
X{n,m}X{n,m}?X{n,m}+

4. 总结

现在再回过头看看研发人员写的那个电话号码的正则表达式

  1. regexp=”^[+]?(\d+)((-?\s?)\d+)*$”

如果这里我们输入很长的一串字符“123123123123123123123……………………89a”,先匹配^[+]与第一个数字1,没有匹配到,在匹配(\d+)与第一个数字1,因为贪婪匹配,一直要匹配到最后的字母a,\d+不在匹配上。

第一次回溯:

这时候就要把这时候把a前面的数字“9”吐出来,前面的所有数字“123123123123123123123……………………8”匹配了正则表达式的前半部分”^[+]?(\d+)”,剩下的就是“9a”与“((-?\s?)\d+)”进行匹配,“9”可以与后面的“\d+”匹配,剩下”a”又没有匹配。

第二次回溯:

把8也吐出来,同样前面的所有数字“123123123123123123123……………………”匹配了正则表达式的前半部分”^[+]?(\d+)”,剩下的就是”89a”与正则表达式的后半部分就行匹配,“\d+”贪婪匹配,匹配到“89”,剩余“a”,没有匹配到。这时候需要注意啦,后半部分的正则也要进行回溯啦。也就是“89”要做一次回溯,“8”与“((-?\s?)\d+)”的“\d+”匹配,剩余”9a”没法匹配,所以其实第二次回溯进行了2次。

以此类推,

第三次回溯:

实际回溯了3次

以此类推……

一共回溯了(1+N)N/2,当N=500的时候,一共回溯了12万5千多次。所以一旦发生回溯,计算量将是巨大的,所以导致CUP占用超过200%。*

因此,在自己写正则表达式的时候,一定不能大意,在实现功能的情况下,还要仔细考虑是否会带来性能隐患。