Skip to content

Java性能优化战术-预分配、预处理、预编译

About 1896 wordsAbout 6 min

性能新书

2026-10-14

预分配

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是固定的,因此在序列化时候可以预先序列化成二进制,而不是每次都再做一次

&lt;product>xxxx&lt;/product>

代码如下

@Benchmark
public byte[] getByteByPreEncode() throws Exception {
  return ProudctXML.getBytes(name);
}

static class ProudctXML{
  //预先编码
  static byte[] productStart = getBytes("&lt;product>");
  static byte[] productEnd = getBytes("&lt;/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+)(.*)”
PreparedStatementJDBC数据库将预编译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%左右。考虑到模板实际应用场景远比这个复杂,比如,如果支持转义符号,那么,传统方式还需要在向前看是否有转义符号,如果有,则认为{}是文本一部分。传统方式在应对越来越多的格式化需求时候,性能会越来越糟糕,而预编译方式则保持性能稳定

知行合一