Android StringBuilder内存碎片优化 前言

前言

在java代码中,字符串拼接是经常存在的事情,使用最多的地方也就是日志输出了。在手机设备上,日志的重要性可能没那么多,但是在iOT设备上,日志往往是救命稻草,因为设备种类多,最重要的是绝大部分厂商不会给你adb入口,因此,像手机领域一些删除日志指令的优化,显然不太合适。

关于内存碎片

工作线程中,对象内存的分配。

  • 【1】线程的栈内存实际上和 TLAB 的一部分,属于私有空间。
  • 【2】大对象通常意义上是占内存很大的对象,比如数组就是大对象,而且数组的虚拟内存必须连续,如果没有足够连续的内存空间,一般会 OOM。
  • 【3】无论是分配对象到栈内存还是 TLAB 区域,一般需要 JIT 逃逸分析和可见性分析
    up-37a9dedb46fa20e11c7d71cfa327ff4bc39.webp

我们知道,同一类型的对象反复创建和销毁会产生一定的内存碎片,如果回收速度较快且创建速度慢的话会出现明显的内存抖动,如果回收速度不理想,会朝着OutOfMemoryError的终极目标走去。说到这里,如果回收速度很快,是不是觉得内存抖动也没什么?显然是不正确的,事实是,任何内存碎片都是有危害的,只不过影响程度不同罢了。对于一些需要连续地址的大对象,危害最大。

注意:内存碎片触发OutOfMemoryError并不一定是内存空间不够了,也有可能是没有找到连续的内存地址。

我们来举个例子:
一些超密集任务情况下LinkedList会频繁创建Node,但是相比ArrayList频繁扩容以及元素变少的时候仍然不释放内存相比,显然前者更好。但是如果任务最大数量和ArrayList容量一样,那么后者显然要比前者好得多。

因此,内存碎片中危害最大的显然是大对象。

注意: 我们这里所说的内存碎片实际上是虚拟内存地址,在实际开发中,物理内存不连续是必然的事,因为涉及内存的分配其实是很复杂的,操作系统有自身的机制去处理这种问题。虚拟内存的不连续是影响app直接因素,因为用户态申请内存往往都是申请虚拟内存。补充一个知识点:在linux中,虚拟内存是可以大于物理内存的,只有写操作时才会真正申请物理内存,这就是为什么malloc申请内存后需要memset填充0或1这种数据的原因。

什么是大对象

大对象确切的说不是包含成员内存总量,而是单一对象除去引用部分超过一定阈值就称为大对象,当超过一定阈值时,就会直接分配到老年代内存区域,一般来说这个阈值默认是12K,而一般能超过此阈值的不是数组就是字符串。

但是,能方便优化的的数组类型只有8种基本类型和常量字符串。

在Android中,你使用最多的非基本类型对象就是字符串了,前面我们说过,字符串一般都有大对象的潜力,在JDK 1.7之后,对字符串常量池进行了特殊的堆空间优化,将其存储位置改为堆空间,同时对intern进行了改造,1.7之前调用intern会复制对象到常量池,1.7之后会将对象的引用添加到常量池中,以此来减少字面量字符串,也就是我们所说的字符串常量。这仅仅做到了对字符串常量的优化。然而,非常量的字符串依旧没有取得较好的优化方法。

不过在Android中,String类是空实现,google在指令执行阶段应该会做一些优化。

对于数组而言,如果他的分配过程内存不连续,相比连续内存的分配危害可能并不大,而连续地址的分配不仅耗时,而且还需要收集各种地址信息。

字符串和数组的特性就是内存连续的,因此数组碎片相比普通碎片更容易造成OOM。

企业微信20240305-081716@2x.png

(网图)
如上图中H[3]类型O[2]的数组,需要连续的空间,当然再申请O[2]是足够的,但是H[3]一旦再去申请,就会OOM,尽管内存空间还是有的。

StringBuilder 内存碎片问题

我们知道,javac和kotlinc负责对代码的前端编译,主要是转换代码为字节码,但是对于String的“加法(str1+ str2 … )”对象的处理会按两种情况处理:

常量传播:

对于无引用常量直接合并为新的字符串,对于有引用的final常量同样进行合并,但是这里有个前提,只限于常量,对于final修饰的引用类型,除了字符串字面量外,包括非常量字符串在类的其他对象不进行优化。

后续我们来说常量传播,因为常量传播还涉及类和对象的初始化,会有很多面试题的解法。

1
2
java复制代码final String m = "10";  
String b = "a" + "b" + 15 + m;

等价于下面代码

1
java复制代码String b = "ab1510";

对字符串直接合并,另外m一般也会优化,不过会在后端编译阶段,而不是前端变异阶段,因为m的已经没有存在的意义了,因此会对m进行擦除,这个优化法叫做:“无用变量擦除”。擦出后可以减少栈帧大小,当然,对于android虚拟机而言,可以避免占用寄存器。

通过StringBuilder拼接

我们稍微改下,就能产生不同的效果

1
2
java复制代码String m = "10";   // 或者 String m = new String("10"); 
String b = "a" + "b" + 15 + m;

等价下面代码

1
2
java复制代码String m = "10";
String b = (new StringBuilder()).append("ab15").append(m).toString();

为什么会出现这种情况呢?
我们知道,String是不可变对象(不可变对象和是不是常量不是绝对关系),但是如果其本身是引用类型,为了防止引用类型多次变动,因此是不会常量传播的,必然会通过保守方式产生String才行。前端编译中,javac和kotlinc使用了StringBuilder来实现这个目的。

很多关于String加法的优化中,使用StringBuilder来代替加法,这种情况显然是还要考虑另外一种情况,如果加法连续是一整句,那么其性能是高于多句的情况。

一般来说建议写成下面的

1
2
3
4
java复制代码  String varA = "a";
String varB = "b";
String varC = "c";
String str = "a="+varA+", b="+varB+", c="+varC;

而不是

1
2
3
4
5
6
7
java复制代码  String varA = "a";
String varB = "b";
String varC = "c";

String str = "a="+varA;
str + = ", b="+varB;
str += ", c="+varC;

显然前者没必要优化,因为javac/kotlinc会帮助处理。但是后者问题就比较大大了,在整个逻辑执行完成总共需要创建3个StringBuilder,那么显然造成了不必要的内存碎片。

StringBuilder 内存碎片问题

理论上来说,普通对象的内存碎片危害可能没有那么大,因为其内存分配本身就是不需要地址连续,但是StringBuilder是不同的,我们在StringBuilder源码中就能发现,其本身持有了char数组,那么内存地址需要连续,其扩容会造成较大的内存岁。有些情况,数组可能不大,但是连续的申请空间会提高OOM的概率,

这里补充一点,char类型占2个字节,每一个字符意味着需要占2个bytes

StringBuilder 内存碎片优化

本篇我们使用享元模式,在C#语言中,微软(Microsoft)也有类似的的方案(参见StringBuilderCache.cs 源码),其次openHTF低延迟交替方案Chronicle-Queue项目也是这种相似的方式。

享元模式

我们知道,优化内存碎片最好的方式就是内存复用,比如Bitmap解码时的inBitmap机制,比如Message对象消息池。

不过,这里仍然有个问题,单线程中出问题的机率很低,但是对于String加法这种情况,可能产生多线程竞争问题,因此,合理的调用机制非常重要。很好,你一定想到了ThreadLocal了吧,显然使用ThreadLocal是可以的,还有使用WeakHashMap也是可以的,不过这里我们使用ThreadLocal,因为这种实现逻辑会更简单,毕竟你更熟悉ThreadLocal而不是WeakHashMap。

另外,我们要知道,解决内存复用的最佳手段是“享元模式”,因此,我们这里利用享元模式来实现StringBuilder的复用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
java复制代码public class StringBuilderFactory {
final static ThreadLocal<LinkedList<StringBuilder>> pool = new ThreadLocal<LinkedList<StringBuilder>>() {
@Nullable
@Override
protected LinkedList<StringBuilder> initialValue() {
//懒加载机制,get时会判断要不要创建
return new LinkedList<StringBuilder>();
}
};
//每个线程最大持有的StringBuilder缓存数量
public static final int MAX_ITEM_EACH_THREAD = 5;
//被复用的StringBuilder容量不能小于10字节
public static final int MIN_CAPACITY = 5;
//被复用的StringBuilder容量不能大于512k,实际情况以你项目为准
public static final int MAX_CAPACITY = 128 * 1024;

public static StringBuilder obtain() {
LinkedList<StringBuilder> builders = pool.get();
if (builders == null || builders.isEmpty()) {
return new StringBuilder();
}
return builders.pop();
}
// 回收方法
public static void recycle(StringBuilder sb) {
if (sb == null || sb.capacity() < MIN_CAPACITY || sb.capacity() > MAX_CAPACITY) {
return; //小于MIN_CAPACITY的容量的对象,不回收了,意义不大
}
LinkedList<StringBuilder> builders = pool.get();
if (builders.size() < MAX_ITEM_EACH_THREAD) {
//缓存池超过的不回收
sb.setLength(0);
builders.offer(sb);
}
}
}

性能测试

我们使用如下代码进行性能测试,为了防止常量传播,我们定义一个非final的字符串,其次中间使用索引次数分割字符串。

测试代码

我们按些图10000次计算,正常情况下每秒100次日志写入已经算多的。另外发现个有趣的现象,超过10万次,String+String 优化效果会很明显,甚至会好于本篇逻辑,但这种情况在需求层面应该不适用,因为很少有10万次的字符串拼接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码String CONSTANTS = "AAAAABBBBAAAAABBBBAAAAABBBBAAAAABBBBAAAAABBBBAAAAABBBBAAAAABBBBAAAAABBBBAAAAABBBBAAAAABBBB";
private int testStringBuilderFactoryPerformance() {
int len = 0;
for (int i = 0; i < 10000; i++) {
StringBuilder stringBuilder = StringBuilderFactory.obtain();
stringBuilder.append(CONSTANTS).append(i).append(CONSTANTS);
len += stringBuilder.length();
StringBuilderFactory.recycle(stringBuilder);
}
return len;
}

private int testStringBuilderPerformance() {
int len = 0;
for (int i = 0; i < 10000; i++) {
String str = CONSTANTS + i + CONSTANTS;
len += str.length();
}
return len;
}

影响因素:

由于JIT对private方法进行了内联,其次对有界循环进行了展开操作,耗时会稍微递减,不过不会影响结果

结果 & 结论:

下面是测试结果

次数/方法 StringBuilder (testStringBuilderPerformance) StringBuilderFactory (testStringBuilderFactoryPerformance)
1 311 ms 52 ms
2 570 ms 42 ms
3 241 ms 37 ms
4 171 ms 23 ms

很明显,在cpu这里已经能看出很明显的差异,使用享元模式有效减少了内存的申请次数,另外,我们按单线程运行推测量,这种内存实际上更少。

testStringBuilderPerformance 占内存较高且有明显的锯齿,而testStringBuilderFactoryPerformance内存比较稳定。

到这里你可能要问,10000次字符串拼接正常情况下也没有,说的很对,确实很少,不过不影响我们优化的效果。其实我们用100次效果也是和10000次一样的,不过需要使用纳秒去计算了,结果仍然是本篇方案效果胜出。

这里我就不改代码了,有问题可在评论中留言。

替换问题

作为一个开发人员,我想你也一样,把String➕String的写法显然已经当成了一种习惯,如果让你主动去替换那么多String➕String,你心情肯定不会好。

可变参数方法

怎么办?封装成可变长方法?

我们以下面的方法为例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public static String $(Object... arrs) {
if (arrs != null && arrs.length != 0) {
StringBuilder obtain = StringBuilderFactory.obtain();

for(int i = 0; i < arrs.length; ++i) {
obtain.append(i);
}

String s = obtain.toString();
StringBuilderFactory.recycle(obtain);
return s;
} else {
return null;
}
}

那么用法

1
java复制代码String str = $("a=",a,"b=",b,"c=",c)

这种方法显然是可以的,虽然➕变成了逗号,总之还能用。

但是,仍然不够最优:

假设我们再新增一个方法$(Object[] arrs),那么此时会编译报错,显然,Object… arrs只不过是Object数组的变形,而字节码方法恰好说明了这点。

1
java复制代码public varargs $S([Ljava/lang/Object;)Ljava/lang/String;

这就好比你传参数的时候又创建了数组,显然这种做法并不是最优的方法。另外我们传入的Object数组,如果走的方式是String.append(Object),那么其性能也不太理想,不过,话说回来在一些简单的情况下,这种方法也是可以的。

但是你会发现,仍然没有写String➕String写的顺手,你大概率也不喜欢这种苦差事。

那么有没有更好的办法呢 ?

再谈StringBuilder扩展问题

其实作为一个开发者,一般情况下使用StringBuilder的情况是少于String➕String的,但是由于Javac和kotlinc的编译优化,使得StringBuilder用法比源代码要多很多。

那么有办法优化StringBuilder么?

一个悲观的消息是,StringBuilder是被final修饰,而其基类只有同包名可以访问,在java中,一些final的修饰使得一些类可扩展性很差,就比如URL类也是封装的很差劲,但你想去改造却非常困难。

因此,我们的优化就得另类一些了,这里我们可以通过ASM替换指令。为什么这么做呢,主要原因是编译前替换难度是比较大的,最好还是编译后替换。

基于ASM 方法替换

我们其实可以创建一个普通对象,让其代理持有StringBuilder即可,为什么这种方法可行呢?我们从前面看到,生成的代码中,以构造方法开始,以toString结束,实际上这个是非常有规律的,我们只需要在new 对象的时候替换为 StringBuilderFactory.obtain()来创建StringBuilder,在toString时进行回收即可。

1
2
java复制代码String m = "10";
(new StringBuilder()).append("ab15").append(m).toString();

接下来,我们定义一个StringAppender,下面方法没有那么完整,只实现了部分方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
java复制代码public class StringAppender {
StringBuilder stringBuilder;

public StringAppender() {
this.stringBuilder = StringBuilderFactory.obtain();
}
public StringAppender(String str) {
this.stringBuilder = StringBuilderFactory.obtain();
append(str);
}

public StringAppender(int c) {
this.stringBuilder = StringBuilderFactory.obtain();
// 这里我们忽略自带容量的构造方法,避免太复杂的筛选
}

public StringAppender append(Object obj) {
this.stringBuilder.append(obj);
return this;
}

public StringAppender append(String str) {
this.stringBuilder.append(str);
return this;
}

public StringAppender append(int value) {
this.stringBuilder.append(value);
return this;
}

//一些其他方法,因篇幅有限,你自己实现吧

@Override
public String toString() {
String msg = stringBuilder.toString();
StringBuilderFactory.recycle(stringBuilder);
return msg;
}
}

下一步,字节码替换,我们通过asm将StringBuilder的调用替换为StringAppender的即可。

这里我们可以使用来实现

1
java复制代码com.android.build.api.instrumentation.AsmClassVisitorFactory

不过,我们涉及到要替换的方法比较多,比如new StringBuilder() 也需要处理:

  • 构造方法有多个 (负责obtain)
  • 所有的StringBuilder公开方法
  • toString方法 (负责回收)

我们使用asm工具实现,那么转换结果要达到StringBuilder替换为StringAppender

我们以StringBuilder#append(String str) 为例子

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码   NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
LDC "\u7b2c"
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ILOAD 3
ICONST_1
IADD
INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
LDC "\u9879\u76ee"
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ALOAD 1

替换为

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码    NEW op/StringAppender
DUP
INVOKESPECIAL op/StringAppender.<init> ()V
LDC "\u7b2c"
INVOKEVIRTUAL op/StringAppender.append (Ljava/lang/String;)Lop/StringAppender;
ILOAD 3
ICONST_1
IADD
INVOKEVIRTUAL op/StringAppender.append (I)Lop/StringAppender;
LDC "\u9879\u76ee"
INVOKEVIRTUAL op/StringAppender.append (Ljava/lang/String;)Lop/StringAppender;
INVOKEVIRTUAL op/StringAppender.toString ()Ljava/lang/String;
ALOAD 1

风险防范

实际上这里有一些比较大的风险就是方法逃逸,如果方法返回的是StringBuilder对象,该如何处理? 另外,如果多次toString调用,那第后续是不是没有数据了?

  • 第一个问题,这种风险是有的,理论上只有private、default方法理论上是最安全的,其次是protected方法也相对较好,即便逃逸也在小范围内。对于public方法,建议从编码角度优化,不要return StringBuilder,减少这种复杂度。
  • 第二个问题虽然也是风险点,但是这种情况极少,如果有,说明你的代码需要一些优化。

适用范围

  • 存在大量拼接的的情况,循环体、方法体、线程上存在很多拼接写入的日志,这个时候要尽可能做到StringBuilder 内存复用
  • 内存小或回收慢的情况

疑问解答

Q: StringBuilder在方法体中使用后就会回收,为什么还要优化呢?

A: StringBuilder中数据是通过char类型数组存储,需要连续的内存地址,但连续的内存地址如果快速申请或者申请多个不释放,比如一个方法体内创建多个StringBuilder对象,再去申请就很容易OOM

Q: 使用ThreadLocal 遇上线程池会有内存泄露的bug ?

A: ThreadLocal内存泄露的原因并不是ThreadLocal本身的问题,而是用法的问题,ThreadLocal泄露的两种处理方法是线程销毁或者ThreadLocal销毁。那遇上线程池本篇的用途是不是就没用了?恰恰相反,在一个app优化中,线程收敛是很重要的,理论上不应该担心这些问题,除非你一直在创建线程且无法销毁和收敛,导致内存泄露,这显然是app开发者自身的问题。正常情况下,能使用StringBuilder的线程池,下次使用StringBuilder的机率仍然很大,不会因为线程池的使用内存无限增大的。

不过对于线程池优化没有任何概念的开发者而言,我们假设线程他知道一些线程名称,可以对未知的线程通过白名单屏蔽。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public static void recycle(StringBuilder sb) {

if(!inThreadWhiteList(Thread.currentThread())){
return;
}

if (sb == null || sb.capacity() < MIN_CAPACITY || sb.capacity() > MAX_CAPACITY) {
return; //小于MIN_CAPACITY的容量的对象,不回收了,意义不大
}
LinkedList<StringBuilder> builders = pool.get();
if (builders.size() < MAX_ITEM_EACH_THREAD) {
//缓存池超过的不回收
sb.setLength(0);
builders.offer(sb);
}
}
}

另外,如果实在不放心,也未必一定要使用ThreadLocal,我们可以使用一个普通的数据链表,在不同线程中使用,要记住的是是obtain和recycle需要和android.os.Message一样: 使用时加锁即可。

Q: 使用本方案后代码变长了 ?

A:我不知道是不是说StringAppender比StringBuilder长?实际上在字节码执行时,是没有这个长短概念,不过包体积优化可能会有影响,如果纯粹追求更短的代码,开启混淆或者重命名StringAppender为更短的不就行了?

总结

到这里本篇就结束了,在本篇我们通过享元模式 + asm实现优化,当然,字节跳动的juejin.cn/post/705261… 文章中,他们的手段其实也是非常先进,比如先删除Log再删除StringBuilder调用的相关优化,彻底避免了内存碎片的出现,很适合手机app方面的优化。

但是,针对国内的一些iOT设备,日志有时是定位问题的唯一手段,因为国内的iOT大部分厂商都不会给你开启ADB的入口,配合度也很差,其次iOT设备占用空间大,操作很不方便,另外就是兼容性差,比起手机来说开发难度要高很多的。业务方面,如果你是手机端开发,且版本稳定的话可以删除StringBuilder的调用,但是如果负面意义较大的话还是保留的好。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%