字符串中的坑和优化

字符串中的坑和优化

马草原 1,281 2024-02-28

字符串中的坑和优化

说起String,大家可能再熟悉不过了,但是大家真的了解String吗?
别太着急下结论,不妨先回答一个问题吧。

问题

String str1= "abc";
String str2= new String("abc");
String str3= str2.intern();

System.out.println(str1==str2);
System.out.println(str2==str3);
System.out.println(str1==str3)

String在Java中是如何实现的?

string

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

共享char数组:

String str1 = "hello";
String str2 = "world";
String str3 = str1.substring(0, 3); 
// 不再使用str1
str1 = null;

=============内存结构图==============
str1: [h][e][l][l][o]
           ^
           |
str3:    [h][e][l]

str1str3共享同一个字符数组,而str3通过偏移量和字符数量来定位字符数组中的字符序列。即便是str1已经不在使用了但是依然无法被释放,因为str3持有str1的引用。如果原始字符串很大的时候即便是我们只截取其中的几个字母但是整个庞大的字符数组依然存在内存无法被回收。


  1. Java7版本开始到Java8版本,JavaString类做了一些改变。String类中不再有offsetcount两个变量了。这样的好处是String对象占用的内存稍微少了些,同时,String.substring方法也不再共享char[],从而解决了可能出现的内存泄漏问题。

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

一个char字符占16位,2个字节。这个情况下,存储单字节编码内的字符(占一个字节的字符)就显得非常浪费。JDK9String类为了节约内存空间,于是使用了占8位,1个字节的byte数组来存放字符串。
新属性coder的作用是,在计算字符串长度或者使用indexOf()函数时,我们需要根据这个字段,判断如何计算字符串长度。coder属性默认有0和1两个值,0代表Latin-1(单字节编码),1代表UTF-16。如果String判断字符串只包含了Latin-1,则coder属性值为0,反之则为1。


String对象的不可变性

无论是哪个版本,String都是由不可变的数组实现的。

好处:
  • 保证String对象的安全性避免被恶意修改。
  • 保证hash属性值不会频繁变更,确保了唯一性,加快字符串的比较和哈希表等数据结构的操作速度
  • 可以实现字符串常量池。在Java中,通常有两种创建字符串对象的方式,一种是通过字符串常量的方式创建,如String str="mcaoyuan";另一种是字符串变量通过new形式的创建,如String str = new String("mcaoyuan")

当代码中使用String str="mcaoyuan"方式创建字符串对象时,JVM首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存
String str = new String("mcaoyuan")这种方式,首先在编译类文件时,mcaoyuan常量字符串将会放入到常量结构中,在类加载时,mcaoyuan将会在常量池中创建;然后在调用new时,JVM命令将会调用String的构造函数,同时引用常量池中的abc字符串,在堆内存中创建一个String对象;最后,str将引用堆中的String对象。


String拼接优化

这是一个老生常谈的问题了。大量字符串拼接请使用StringBuilder或是StringBuffer

我听到过这样的说法:JDK很智能,他会自动把+拼接编译成StringBuilder。不可否认高版本JDK确实会这样做,但是任有弊端。

String str = "";

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

编译后的Class文件经过反编译后:

String str = "";

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

可以看到转换了,但是又没卵用。。。 每次循环都new了一个新的StringBuilder对象。

当然,随着JDK的版本更新编译优化可能不同。因此更应该显式使用StringBuilder而不是依赖编译器的优化,因为优化结果是不确定的。


字符串的Split()方法

Split()方法使用了正则表达式实现了其强大的分割功能。但是正则表达式的性能很不稳定的,使用不恰当会引起回溯问题,可能导致CPU被吃满。
所以我们应该慎重使用Split()方法,可以用String.indexOf()方法代替Split()方法完成字符串的分割。如果实在无法满足需求,你就在使用Split()方法时,对回溯问题加以重视就可以了。

正则表达式的回溯机制是指在匹配过程中,当某一部分正则表达式无法匹配当前输入时,正则引擎将回溯到之前的状态,尝试其他可能的匹配路径,直到找到匹配或者穷尽所有可能性
因为正则回溯问题导致CPU吃满的案例:
https://cloud.tencent.com/developer/article/1539449


字符串优化案例

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();
// 如果city存在于常量池将直接返回常量池的引用,否则把当前city加入常量池
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());

通过对citycountryCoderegion调用intern()方法,确保了相同的城市、国家码和地区只在内存中存在一份