<audio id="audio" title="03 | 字符串性能优化不容小觑，百M内存轻松存储几十G数据" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/25/07/25e52e0c631df6e24c43c0eebc0e3d07.mp3"></audio>

你好，我是刘超。

从第二个模块开始，我将带你学习Java编程的性能优化。今天我们就从最基础的String字符串优化讲起。

String对象是我们使用最频繁的一个对象类型，但它的性能问题却是最容易被忽略的。String对象作为Java语言中重要的数据类型，是内存中占据空间最大的一个对象。高效地使用字符串，可以提升系统的整体性能。

接下来我们就从String对象的实现、特性以及实际使用中的优化这三个方面入手，深入了解。

在开始之前，我想先问你一个小问题，也是我在招聘时，经常会问到面试者的一道题。虽是老生常谈了，但错误率依然很高，当然也有一些面试者答对了，但能解释清楚答案背后原理的人少之又少。问题如下：

通过三种不同的方式创建了三个对象，再依次两两匹配，每组被匹配的两个对象是否相等？代码如下：

```
String str1= &quot;abc&quot;;
String str2= new String(&quot;abc&quot;);
String str3= str2.intern();
assertSame(str1==str2);
assertSame(str2==str3);
assertSame(str1==str3)

```

你可以先想想答案，以及这样回答的原因。希望通过今天的学习，你能拿到满分。

## String对象是如何实现的？

在Java语言中，Sun公司的工程师们对String对象做了大量的优化，来节约内存空间，提升String对象在系统中的性能。一起来看看优化过程，如下图所示：

<img src="https://static001.geekbang.org/resource/image/35/6d/357f1cb1263fd0b5b3e4ccb6b971c96d.jpg" alt="">

**1.在Java6以及之前的版本中**，String对象是对char数组进行了封装实现的对象，主要有四个成员变量：char数组、偏移量offset、字符数量count、哈希值hash。

String对象是通过offset和count两个属性来定位char[]数组，获取字符串。这么做可以高效、快速地共享数组对象，同时节省内存空间，但这种方式很有可能会导致内存泄漏。

**2.从Java7版本开始到Java8版本**，Java对String类做了一些改变。String类中不再有offset和count两个变量了。这样的好处是String对象占用的内存稍微少了些，同时，String.substring方法也不再共享char[]，从而解决了使用该方法可能导致的内存泄漏问题。

**3.从Java9版本开始，**工程师将char[]字段改为了byte[]字段，又维护了一个新的属性coder，它是一个编码格式的标识。

工程师为什么这样修改呢？

我们知道一个char字符占16位，2个字节。这个情况下，存储单字节编码内的字符（占一个字节的字符）就显得非常浪费。JDK1.9的String类为了节约内存空间，于是使用了占8位，1个字节的byte数组来存放字符串。

而新属性coder的作用是，在计算字符串长度或者使用indexOf（）函数时，我们需要根据这个字段，判断如何计算字符串长度。coder属性默认有0和1两个值，0代表Latin-1（单字节编码），1代表UTF-16。如果String判断字符串只包含了Latin-1，则coder属性值为0，反之则为1。

## String对象的不可变性

了解了String对象的实现后，你有没有发现在实现代码中String类被final关键字修饰了，而且变量char数组也被final修饰了。

我们知道类被final修饰代表该类不可继承，而char[]被final+private修饰，代表了String对象不可被更改。Java实现的这个特性叫作String对象的不可变性，即String对象一旦创建成功，就不能再对它进行改变。

**Java这样做的好处在哪里呢？**

第一，保证String对象的安全性。假设String对象是可变的，那么String对象将可能被恶意修改。

第二，保证hash属性值不会频繁变更，确保了唯一性，使得类似HashMap容器才能实现相应的key-value缓存功能。

第三，可以实现字符串常量池。在Java中，通常有两种创建字符串对象的方式，一种是通过字符串常量的方式创建，如String str=“abc”；另一种是字符串变量通过new形式的创建，如String str = new String(“abc”)。

当代码中使用第一种方式创建字符串对象时，JVM首先会检查该对象是否在字符串常量池中，如果在，就返回该对象引用，否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建，节约内存。

String str = new String(“abc”)这种方式，首先在编译类文件时，"abc"常量字符串将会放入到常量结构中，在类加载时，“abc"将会在常量池中创建；其次，在调用new时，JVM命令将会调用String的构造函数，同时引用常量池中的"abc” 字符串，在堆内存中创建一个String对象；最后，str将引用String对象。

**这里附上一个你可能会想到的经典反例。**

平常编程时，对一个String对象str赋值“hello”，然后又让str值为“world”，这个时候str的值变成了“world”。那么str值确实改变了，为什么我还说String对象不可变呢？

首先，我来解释下什么是对象和对象引用。Java初学者往往对此存在误区，特别是一些从PHP转Java的同学。在Java中要比较两个对象是否相等，往往是用==，而要判断两个对象的值是否相等，则需要用equals方法来判断。

这是因为str只是String对象的引用，并不是对象本身。对象在内存中是一块内存地址，str则是一个指向该内存地址的引用。所以在刚刚我们说的这个例子中，第一次赋值的时候，创建了一个“hello”对象，str引用指向“hello”地址；第二次赋值的时候，又重新创建了一个对象“world”，str引用指向了“world”，但“hello”对象依然存在于内存中。

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

## String对象的优化

了解了String对象的实现原理和特性，接下来我们就结合实际场景，看看如何优化String对象的使用，优化的过程中又有哪些需要注意的地方。

### 1.如何构建超大字符串？

编程过程中，字符串的拼接很常见。前面我讲过String对象是不可变的，如果我们使用String对象相加，拼接我们想要的字符串，是不是就会产生多个对象呢？例如以下代码：

```
String str= &quot;ab&quot; + &quot;cd&quot; + &quot;ef&quot;;

```

分析代码可知：首先会生成ab对象，再生成abcd对象，最后生成abcdef对象，从理论上来说，这段代码是低效的。

但实际运行中，我们发现只有一个对象生成，这是为什么呢？难道我们的理论判断错了？我们再来看编译后的代码，你会发现编译器自动优化了这行代码，如下：

```
String str= &quot;abcdef&quot;;

```

上面我介绍的是字符串常量的累计，我们再来看看字符串变量的累计又是怎样的呢？

```
String str = &quot;abcdef&quot;;

for(int i=0; i&lt;1000; i++) {
      str = str + i;
}

```

上面的代码编译后，你可以看到编译器同样对这段代码进行了优化。不难发现，Java在进行字符串的拼接时，偏向使用StringBuilder，这样可以提高程序的效率。

```

String str = &quot;abcdef&quot;;

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

```

**综上已知：**即使使用+号作为字符串的拼接，也一样可以被编译器优化成StringBuilder的方式。但再细致些，你会发现在编译器优化的代码中，每次循环都会生成一个新的StringBuilder实例，同样也会降低系统的性能。

所以平时做字符串拼接的时候，我建议你还是要显示地使用String Builder来提升系统性能。

如果在多线程编程中，String对象的拼接涉及到线程安全，你可以使用StringBuffer。但是要注意，由于StringBuffer是线程安全的，涉及到锁竞争，所以从性能上来说，要比StringBuilder差一些。

### 2.如何使用String.intern节省内存？

讲完了构建字符串，我们再来讨论下String对象的存储问题。先看一个案例。

Twitter每次发布消息状态的时候，都会产生一个地址信息，以当时Twitter用户的规模预估，服务器需要32G的内存来存储地址信息。

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

```

考虑到其中有很多用户在地址信息上是有重合的，比如，国家、省份、城市等，这时就可以将这部分信息单独列出一个类，以减少重复，代码如下：

```

public class SharedLocation {

	private String city;
	private String region;
	private String countryCode;
}

public class Location {

	private SharedLocation sharedLocation;
	double longitude;
	double latitude;
}

```

通过优化，数据存储大小减到了20G左右。但对于内存存储这个数据来说，依然很大，怎么办呢？

这个案例来自一位Twitter工程师在QCon全球软件开发大会上的演讲，他们想到的解决方法，就是使用String.intern来节省内存空间，从而优化String对象的存储。

具体做法就是，在每次赋值的时候使用String的intern方法，如果常量池中有相同值，就会重复使用该对象，返回对象引用，这样一开始的对象就可以被回收掉。这种方式可以使重复性非常高的地址信息存储大小从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());

```

**为了更好地理解，我们再来通过一个简单的例子，回顾下其中的原理：**

```
String a =new String(&quot;abc&quot;).intern();
String b = new String(&quot;abc&quot;).intern();
    	  
if(a==b) {
    System.out.print(&quot;a==b&quot;);
}

```

输出结果：

```
a==b

```

在字符串常量中，默认会将对象放入常量池；在字符串变量中，对象是会创建在堆内存中，同时也会在常量池中创建一个字符串对象，String对象中的char数组将会引用常量池中的char数组，并返回堆内存对象引用。

如果调用intern方法，会去查看字符串常量池中是否有等于该对象的字符串的引用，如果没有，在JDK1.6版本中会复制堆中的字符串到常量池中，并返回该字符串引用，堆内存中原有的字符串由于没有引用指向它，将会通过垃圾回收器回收。

在JDK1.7版本以后，由于常量池已经合并到了堆中，所以不会再复制具体字符串了，只是会把首次遇到的字符串的引用添加到常量池中；如果有，就返回常量池中的字符串引用。

了解了原理，我们再一起看下上边的例子。

在一开始字符串"abc"会在加载类时，在常量池中创建一个字符串对象。

创建a变量时，调用new Sting()会在堆内存中创建一个String对象，String对象中的char数组将会引用常量池中字符串。在调用intern方法之后，会去常量池中查找是否有等于该字符串对象的引用，有就返回引用。

创建b变量时，调用new Sting()会在堆内存中创建一个String对象，String对象中的char数组将会引用常量池中字符串。在调用intern方法之后，会去常量池中查找是否有等于该字符串对象的引用，有就返回引用。

而在堆内存中的两个对象，由于没有引用指向它，将会被垃圾回收。所以a和b引用的是同一个对象。

如果在运行时，创建字符串对象，将会直接在堆内存中创建，不会在常量池中创建。所以动态创建的字符串对象，调用intern方法，在JDK1.6版本中会去常量池中创建运行时常量以及返回字符串引用，在JDK1.7版本之后，会将堆中的字符串常量的引用放入到常量池中，当其它堆中的字符串对象通过intern方法获取字符串对象引用时，则会去常量池中判断是否有相同值的字符串的引用，此时有，则返回该常量池中字符串引用，跟之前的字符串指向同一地址的字符串对象。

以一张图来总结String字符串的创建分配内存地址情况：

<img src="https://static001.geekbang.org/resource/image/b1/50/b1995253db45cd5e5b7bc1ded7cbdd50.jpg" alt="">

使用intern方法需要注意的一点是，一定要结合实际场景。因为常量池的实现是类似于一个HashTable的实现方式，HashTable存储的数据越大，遍历的时间复杂度就会增加。如果数据过大，会增加整个字符串常量池的负担。

### 3.如何使用字符串的分割方法？

最后我想跟你聊聊字符串的分割，这种方法在编码中也很最常见。Split()方法使用了正则表达式实现了其强大的分割功能，而正则表达式的性能是非常不稳定的，使用不恰当会引起回溯问题，很可能导致CPU居高不下。

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

## 总结

这一讲中，我们认识到做好String字符串性能优化，可以提高系统的整体性能。在这个理论基础上，Java版本在迭代中通过不断地更改成员变量，节约内存空间，对String对象进行优化。

我们还特别提到了String对象的不可变性，正是这个特性实现了字符串常量池，通过减少同一个值的字符串对象的重复创建，进一步节约内存。

但也是因为这个特性，我们在做长字符串拼接时，需要显示使用StringBuilder，以提高字符串的拼接性能。最后，在优化方面，我们还可以使用intern方法，让变量字符串对象重复使用常量池中相同值的对象，进而节约内存。

最后再分享一个个人观点。那就是千里之堤，溃于蚁穴。日常编程中，我们往往可能就是对一个小小的字符串了解不够深入，使用不够恰当，从而引发线上事故。

比如，在我之前的工作经历中，就曾因为使用正则表达式对字符串进行匹配，导致并发瓶颈，这里也可以将其归纳为字符串使用的性能问题。具体实战分析，我将在04讲中为你详解。

## 思考题

通过今天的学习，你知道文章开头那道面试题的答案了吗？背后的原理是什么？

## 互动时刻

今天除了思考题，我还想和你做一个简短的交流。

上两讲中，我收到了很多留言，在此非常感谢你的支持。由于前两讲是概述内容，主要是帮你建立对性能调优的整体认识，所以相对来说重理论、偏基础。但我发现，很多同学都有这样迫切的愿望，那就是赶紧学会使用排查工具，监测分析性能，解决当下的一些问题。

我这里特别想分享一点，其实性能调优不仅仅是学会使用排查监测工具，更重要的是掌握背后的调优原理，这样你不仅能够独立解决同一类的性能问题，还能写出高性能代码，所以我希望给你的学习路径是：夯实基础-结合实战-实现进阶。

最后，欢迎你积极发言，讨论思考题或是你遇到的性能问题都可以，我会知无不尽。也欢迎你点击“请朋友读”，把今天的内容分享给身边的朋友，邀请他一起讨论。


