总结五种Java代码性能优化技巧
预处理
预处理是指对于需要反复调用的代码,可以尝试提取出公共的只读代码块,处理一次并保留处理结果。反复调用的时候,直接引用处理结果即可,这样能避免每次都处理公共内容。比如一个配置类Confg,其中有一个属性是黑名单列表,它是用逗号分隔的字符串,请求会检查调用方是否在黑名单里,如果在黑名单里,则拒绝方法,检测代码如下:
如果这段代码的调用量比较大,则可以将第二行代码优化-下,黑名单列表可以事先转化成Set,不必每次判断的时候都构造Set,代码如下:
在微服务调用中,对象常常需要序列化JSON、XML,系统之间的底层传输数据都是二进制格式的。这些数据有一部分是静态内容, 比如XML标签、JSON 的Key,没有必要每次都将这些字符串转码。序列化工具可以事先把这部分不变的内容预先处理,以提高性能。2009年,笔者在竞标某通信集团核心系统消息中间件方案的时候,就通过这种预处理办法提高了生成XML报文及序列化报文的性能。
再比如模板引擎,在渲染模板的时候,静态内容转码占据了模板渲染消耗的大部分时间。可以事先把模板静态内容转为byte[]。以下是Beetl模板引擎实现静态文本渲染的伪代码:
预分配
JDK中存在大量预先分配空间的代码,比如StringBuilder, 会初始分配一段空间, 而不必在每次调用append时才分配:
当调用append方法的时候,会先检测分配的空间是否足够,如果足够,则不需要增加空间。比如,调用append(char c)方法:
count表示当前使用的长度,ensureCapacityInternal方法用于确保新增加的内容能放到value数组中,如果预先分配的缓冲区大小足够,则不需要任何操作。如果不够,则会分配一个更大的缓存区,并且把以前的内容复制到新的缓冲区中。
Arrays.copyOf方法会按照期望的newCapacity创建一个 新的数组,并调用System.arraycopy。把original内容复制到新的数组中。
MessagePack是一个二进制序列化工具,采用另外一种扩容办法,在需要扩容的时候,会创建一个新的byte[],把新增内容放到新的byte[]中, MessagePack 会像链表那样维护多个byte[]以避免内容复制。然而,MessagePack返回的序列化结果是byte[], 还需要一块连续空间。
除了StringBuilder,JDK还有大量预分配的类,比如集合类框架,在使用的时候也需要预先分配一个空间以提高性能:
预编译
SimpleDateFormat 会预先把格式化字符串“编译”成- -种中间格式,JDK 中有大量的类都会采用这种预先编译技术,以提高运行时的性能。下表是JDK中预编译技术的一些类。
当涉及格式化、序列化的工具类时,预编译成中间格式是- -种提 高性能的办法,比如在优化一个日志输出框架的时候就采用了这种办法。
一个日志框架会按照指定格式输出参数内容,例如:
这段代码很简单,通过String,indexOf 找到{},然后依次替换成变量名字。代码没有考虑各种复杂情况,比如转义符号“\”后的{}不应该替换成变量,而应该原样输出。
这段代码还有较大的优化空间,比如不必每次都解析format,可以将format 转化成中间格式,把format看成由一系 列Token组成的Template。Token 包含Static Token和VarToken,因此上面的format字符串可以按如下方式构造:
StaticToken和VarToken都实现了Token接口的render方法,每个Token负责各自的输出,比如StaticToken输出静态文本:
运行MesageFormatTest类,在笔者的机器上采用预编译方式,和传统方式相比的结果如下:
通过预编译方式,性能提升了30%左右,虽然这是目前优化后性能提升最少的,但模板实际的应用场景远比这个复杂,如果支持转义符号,那么传统方式还需要再向前看是否有转义符号,如果有,则认为{是文本的一部分。传统方式在应对越来越多的格式化需求时,性能会越来越糟糕,而预编译方式则保持了稳定的性能。
很多格式化、序列化工具都会采用类似这种预编译的方式以提升性能,比如对象的属性克隆功能,开源工具为了保证高效赋值,并没有采用反射。以CGLIB的BeanCopier为例,它会在运行期间生成一个目标类的克隆工具类,这就跟我们手写Java代码进行克隆是一样的,因此它在克隆对象上的性能远高于Apache的BeanUtils中的copyProperties方法,MapStruct. Selma工具在编译期生成Java代码来实现克隆功能,同样有超高的性能,我们会在第10章和第11章中分别进行说明。
预先编码
字符串构造时了解到把字符串转为byte需要消耗一定的CPU资源(这种常用在微服务中,把内容序列化成二进制格式发送到微服务系统),如果每次都需要把静态(不变)的文本转成byte[],显得有点浪费CPU资源,因此,可以预先将静态文本转化为byte[]。例如,以下是一个简单的XML报文:
考虑到字符串常量可以预先转成字节存储起来,因此可以使用专有的类ProudctXML 来序列化此XML报文:
谨慎使用 Exception
抛出异常在Java中有相当大的代价,运行TryCatchTest测试,异常和正常返回结果两种情况的性能差别非常大:
可以看到,通过返回异常码比抛出异常的性能高出4个数量级,因此我们应该避免把正常的返回错误结果使用异常来代替。
抛出异常之所以会导致性能降低,是因为Java代码构造异常对象时需要一个填写异常栈的过程。在Throwable类中有一个方法:
filInStackTrace是一个Native方法,会填写异常栈。可想而知,这是一个异常耗时的操作,优化办法是自定义一个异常,重载filInStackTrace方法,不执行filInStackTrace操作。
抛出这样的异常,性能仍然不理想,因为虚拟机对异常的捕捉和处理也是非常耗时的操作。默认情况下,虚拟机会对某个方法频繁地抛出某些异常做Fast Throw优化。如果检测到在代码中的某个位置连续多次抛出同一类型的异常,则决定用Fast Throw方式来抛出异常,异常栈信息不会被填写。这种异常抛出的速度非常快,因为不需要在堆里分配内存,也不需要构造完整的异常栈信息。以下异常会使用Fast Throw进行优化:
NullPointerException;
ArithmeticException;
ArrayIndexOutOfBoundsException;
ArrayStoreException;
ClassCastException。
这种优化方式虽然提高了系统性能,但会导致异常栈消失,从而无法快速定位到错误代码,我们不得不找到更早的日志文件(也许已经被压缩处理了),查看是否包含最初的异常栈。曾经一个线.上系统因为这种空指针异常栈消失而导致巨大损失。
为了避免这种异常栈优化,可以通过虚拟机参数-XX:-OmitStackTraceInFastThrow来忽略异常优化。.