Java性能优化战术-预分配、预处理、预编译
预分配
JDK里存在大量预先分配空间的的代码,比如StringBuilder, 会初始分配一段空间,而不必每次调用append才分配
public StringBuilder() {
super(16);
}StringBuilder的父类会初始化一个16长的数组
AbstractStringBuilder(int capacity) {
value = new char[capacity];
}这样,当调用append方法的时候,会先检测一下分配的空间是否足够,如果足够,则不需要增加空间
比如,调用append(char c) 方法
@Override
public AbstractStringBuilder append(char c) {
ensureCapacityInternal(count + 1);
value[count++] = c;
return this;
}count 表示当前使用的长度,ensureCapacityInternal方法用于确保新增加的内容能放到value数组里,如果预先分配的缓冲区大小足够,则不需要任何操作。如果不够,则会分配的一个更大的缓存区,并且把以前的内容拷贝到新的缓冲区
//ensureCapacityInternal 调用expandCapacity 扩容
void expandCapacity(int minimumCapacity) {
int newCapacity = //计算长度
value = Arrays.copyOf(value, newCapacity);
}Arrays.copyOf方法会按照期望的newCapacity创建一个新的数组,并调用System.arraycopy把original内容拷贝到新的数组里
public static char[] copyOf(char[] original, int newLength) {
char[] copy = new char[newLength];
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}其他第三方工具也使用了预分配机制增强性能
- FastJSON,Jackson这些工具会预先分配一定大小的字符数组,比如8K大小的空间,以供序列化成字符串时候使用此字符数组,而不是每次序列化都创建。
- MessagePack是类似Jackson一样的序列化工具,它会在预先分配内存空间(比如byte[])不足情况下,不采用扩容方式,而是再分配一个新的内存空间,并用链表维护多个byte[].然而,然而MessagePack在返回序列化结果时候,还是需要创建一块连续byte[]空间。
- Netty 借鉴了Freebsd中的jemalloc方法,预选创建多个不同大小的内存块,比如4M,以供从IO读取字节的时候,存储在预先创建的这些内存块里。Netty同时提供了很多的监控方法以验证内存分配效率。
jemalloc算法是为了高并发,多线程设计的内存分配管理算法,用于各种主流操作系统和高性能工具。详细描述其算法超过了本书范围,读者可以阅读Netty相关内存管理文章来了解此算法
预处理
预处理是指对于需要反复调用的代码,可以尝试提取出公共的只读代码块,需要处理一次。反复调用的时候,直接引用处理结果,这样能避免每次都处理公共内容
比如一个配置类Config,其中有个属性是黑名单列表,是用逗号分割的字符串,请求会检查调用方是否在黑名单里,如果是黑名单,则拒绝方法,检测代码如下
String black = serviceConfig.getBlack();
Set set = new HashSet(Arrays.asList(black.split(",")));
boolean forbidAccess = set.contains(requestSource);如果这段代码调用量比较大,可以将第二行代码优化一下,黑名单列表转化成Set事先可以构造好,不必每次构造,如下代码
public class NewServiceConfig{
String black = null;
Set blackSet = new HashSet<>();
public NewServiceConfig(String black){
this.black = black;
this.blackSet.addAll(Arrays.asList(black.split(",")));
}
public Set getBlackSet() {
return blackSet;
}
}这样,每次调用,可以使用如下代码判断
boolean forbidAccess = serviceConfig.getBlackSet().contains(requestSource);另外一个预先处理的例子是预先编码,如下XML需要二进制序列化后传输,其中节点名proudct是固定的,因此在序列化时候可以预先序列化成二进制,而不是每次都再做一次
<product>xxxx</product>代码如下
@Benchmark
public byte[] getByteByPreEncode() throws Exception {
return ProudctXML.getBytes(name);
}
static class ProudctXML{
//预先编码
static byte[] productStart = getBytes("<product>");
static byte[] productEnd = getBytes("</product>");
//获取xml报文的二进制数组
public static byte[] getXML(String productName) throws IOException {
ByteArrayOutputStream os = new ByteArrayOutputStream();
os.write(productStart);
os.write(productName.getBytes("UTF-8"));
os.write(productEnd);
return os.toByteArray();
}
private static byte[] getBytes(String content){
// 字符转二进制,在此省略
}
}预编译
预编译也是预处理的一种,它会预先把字符串编译成中间格式以方便字符串处理,比如SimpleDateFormt 会预先把 格式化字符串预先"编译“成一种中间格式,JDK中有大量的类都会采用这种预先编译技术,以提供运行时的性能
| 类 | 说明 | 举例 |
|---|---|---|
| SimpleDateFormat | 日期格式化 | "yyyy-MM-dd" |
| DecimalFormat | 浮点数格式化 | "##.#" |
| MessageFormat | 文本格式化 | "hello,{0}" |
| Pattern | 正则表达式 | “(\D*)(\d+)(.*)” |
| PreparedStatement | JDBC | 数据库将预编译SQL语句获得执行路径,不需要每次都重复此过程 |
当涉及到格式化,序列化的的工具类,为了提高性能,预编译成中间格式是一种提高性能办法,比如在优化一个日志输出框架的时候,就采用了这种办法
一个日志框架,会按照指定格式输出参数内容,比如
String format = "result a = {} ,b={} ";
int para1 = 12;
int para2 = 15;
log.message(foramt,para1,para2);调用上述方法,期望得到的日志是
result a= 12 ,b=15在meesage方法被调用的时候,会解析format中出现的符号“{}",按照顺序替换成传入的参数,代码如下
//MessageFormatTest
protected String msgFormat(Writer out,String pattern, Object... args) throws IOException {
int index = -1;
int start = 0;
int varIndex = 0;
while ((index = pattern.indexOf("{}", start)) != -1) {
out.write(pattern.substring(start, index));
start = index + 2;
out.append(String.valueOf(args[varIndex++]));
}
// 剩下部分输出
if (start < pattern.length()) {
out.append(pattern.substring(start));
}
return out.toString();
}这段代码很简单,通过String.indexOf 找到{},然后依次替换成变量名字,代码没有考虑各种复杂情况,比如转义,{}, 不应该替换变量.
这段代码在有较大优化空间,不必每次都解析fomrat,可以将format 转化成中间格式,可以把format 看成有一系列Token组成的Template,Token包含StaticToken 和 VarToken,因此上面的format字符串,可以如下构造
StaticToken("result a=") VarToken(0) StaticToken(" ,b=") VarToken(1) StaticToken(" ")StaticToken和VarToken 都实现了Token接口得render方法,每个Token负责各自的输出,比如StaticToken 输出静态文本
public interface Token {
public void render(Context ctx) throws IOException;
}Context类代表上下文,目前有俩个属性
public class Context {
Writer out;
Object[] args;
}out表示输出,args表示模板的输入参数
StaticToken类用于输出静态文本
public class StaticTextToken implements Token {
private String text;
public StaticTextToken(String text){
this.text = text;
}
@Override
public final void render(Context ctx) throws IOException {
ctx.getWriter().append(text);
}
}VarToken用于输出变量 如下
public class VarToken implements Token {
int varIndex = 0;
public VarToken(int varIndex){
this.varIndex = varIndex;
}
@Override
public final void render(Context ctx) throws IOException {
Object obj = ctx.getArgs()[varIndex];
ctx.getWriter().append(String.valueOf(obj));
}
}可以构造一个Template类,包含了Tonkes数组
public class Template {
List<Token> tokens;
public Template(List<Token> tokens){
this.tokens = tokens;
}
public String render(Writer out,Object... args) throws IOException {
Context ctx = new Context(out,args);
for(Token token:tokens){
token.render(ctx);
}
return out.toString();
}
}运行MessageFormatTest类,在我的机器上采用预编译方式,和传统方式,结果如下
Benchmark Mode Score Units
c.i.c.c.MessageFormatTest.format thrpt 2991622.741 ops/s
c.i.c.c.MessageFormatTest.templateFormat thrpt 4012324.889 ops/s通过预编译方式,性能提升了30%左右。考虑到模板实际应用场景远比这个复杂,比如,如果支持转义符号,那么,传统方式还需要在向前看是否有转义符号,如果有,则认为{}是文本一部分。传统方式在应对越来越多的格式化需求时候,性能会越来越糟糕,而预编译方式则保持性能稳定
