一、介绍String对象是我们使用频率很高的对象类型,但是它的性能问题却很容易被忽视。String对象作为Java语言中的一种重要数据类型,是一个在内存中占用较大空间的对象。有效地使用字符串可以提高系统的整体性能。2、String对象的实现在Java语言中,Sun的工程师对String对象做了很多优化,以节省内存空间,提高String对象在系统中的性能。1、在Java6及之前的版本中,String对象是对char数组进行封装的对象。它主要有四个成员变量:char数组、offset偏移量、字符计数、hash值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字节的字节数组来存储字符串。新属性编码器的作用是在计算字符串长度或者使用indexOf()函数时,需要根据这个字段判断如何计算字符串长度。coder属性默认有0和1两个值,0代表Latin-1(单字节编码),1代表UTF-16。如果String判断字符串只包含Latin-1,则coder属性的值为0,否则为1。3、String对象不可变的原因了解了String对象的实现后,有没有发现String类在实现代码中被final关键字修饰,变量char数组也是被final修饰的。我们知道,一个被final修饰的类,意味着该类不能被继承,而char[]被final+private修饰,意味着String对象不能被改变。Java实现的这种特性称为String对象的不可变性,即String对象一旦创建成功,就不能再改变。四、String对象不变性的好处1、保证String对象的安全性。假设String对象是可变的,那么String对象就有可能被恶意修改。2、保证hash属性的值不经常变化,保证唯一性,这样只有像HashMap这样的容器才能实现相应的key-value缓存功能。3.可以实现一个字符串常量池。在Java中,创建字符串对象通常有两种方式,一种是创建字符串常量,如Stringstr="abc";另一种是通过new创建一个字符串变量,比如Stringstr=newString("abc")。当代码使用第一种方法创建字符串对象时,JVM会先检查该对象是否在字符串常量池中,如果是则返回对象引用,否则会在常量池中创建一个新的字符串。该方法可以减少重复创建具有相同值的字符串对象,节省内存。Stringstr=newString(“abc”)这样,首先在编译class文件的时候,会将“abc”常量字符串放入常量结构体中,等到类加载时,“abc”就会在常量结构体中池创建;其次,调用new时,JVM命令会调用String的构造函数,同时引用常量池中的“abc”字符串,并在堆内存中创建一个String对象;最后,str将引用String对象。五、String对象的优化1、超大字符串如何构建?在编程的过程中,字符串的拼接是很常见的。我之前说过String对象是不可变的。如果我们使用String对象来添加拼接我们想要的字符串,会不会生成多个对象?例如下面的代码:Stringstr="ab"+"cd"+"ef";分析代码可知:首先会生成ab对象,然后生成abcd对象,最后生成abcdef对象。理论上,这段代码是低效的。但是在实际运行中,我们发现只生成了一个对象。为什么是这样?我们的理论判断错了吗?我们再看编译后的代码,会发现编译器自动优化了这行代码,如下:Stringstr="abcdef";以上就是字符串常量的累加,我们来看看字符串变量的累加是怎样的?Stringstr="abcdef";for(inti=0;i<1000;i++){str=str+i;}上面的代码编译之后,可以看到编译器也对这段代码进行了优化。不难发现,Java在拼接字符串的时候更喜欢使用StringBuilder,这样可以提高程序的运行效率。Stringstr="abcdef";for(inti=0;i<1000;i++){str=(newStringBuilder(String.valueOf(str))).append(i).toString();}总之:即使使用The+号用作字符串的连接,也可以被编译器优化成StringBuilder。但是仔细看就会发现,在编译器优化过的代码中,每次循环都会生成一个新的StringBuilder实例,这样也会降低系统的性能。所以在做字符串拼接的时候,我建议还是显式的使用StringBuilder来提高系统性能。如果在多线程编程中String对象的拼接涉及到线程安全,可以使用StringBuffer。但需要注意的是,由于StringBuffer是线程安全的,涉及到锁竞争,所以在性能上比StringBuilder差。2.如何使用String.intern来节省内存?构建完字符串之后,我们来讨论一下String对象的存储。我们先来看一个案例。Twitter每次发布消息状态时,都会生成一个地址信息。根据当时Twitter用户的预估规模,服务器需要32G的内存来存储地址信息。publicclassLocation{privateStringcity;privateStringregion;privateStringcountryCode;privatedoublelongitude;privatedoublelatitude;}考虑到很多用户有重叠的地址信息,比如国家、省份、城市等,这部分信息可以单独列为一个类,减少重复,将代码如下:publicclassSharedLocation{privateStringcity;privateStringregion;privateStringcountryCode;}publicclassLocation{privateSharedLocationsharedLocation;doublelongitude;doublelatitude;}通过优化,数据存储大小减少到20G左右。但是对于内存中存储的数据来说,还是很大的。我应该怎么办?这个案例来自一位Twitter工程师在QCon全球软件开发大会上的演讲。他们想到的解决方案是使用String.intern来节省内存空间。从而优化了String对象的存储。具体方法是每次赋值的时候使用String的intern方法。如果常量池中存在相同的值,则该对象会被重用,并返回对象引用,这样就可以回收原来的对象。这种方法可以将高度重复的地址信息的存储大小从20G减少到数百兆字节。SharedLocationsharedLocation=newSharedLocation();sharedLocation.setCity(messageInfo.getCity().intern());sharedLocation.setCountryCode(messageInfo.getRegion().intern());sharedLocation.setRegion(messageInfo.getCountryCode().intern()));Locationlocation=newLocation();location.set(sharedLocation);location.set(messageInfo.getLongitude());location.set(messageInfo.getLatitude());为了更好的理解,我们再看一个简单的例子,Review原理:Stringa=newString("abc").intern();Stringb=newString("abc").intern();if(a==b){System.out.print("a==b");}输出结果:a==b字符串常量中,默认将对象放入常量池;在string变量中,会在堆内存中创建对象,也会在常量池中创建一个string对象,复制到堆内存对象中,并返回堆内存对象引用。如果调用了intern方法,会检查字符串常量池中是否有对等于该对象的字符串的引用。如果不存在,在JDK1.6版本中会将堆中的字符串复制到常量池中,并返回该字符串的Reference,堆内存中的原始字符串将被垃圾回收器回收,因为没有引用指向它。JDK1.7版本之后,由于常量池已经合并到堆中,所以不会复制具体的字符串,而是将第一次遇到的字符串的引用添加到常量池中;如果有,则返回常量池中的String引用。了解了原理之后,我们一起来看上面的例子。一开始字符串“abc”会在加载类的时候在常量池中创建一个字符串对象。创建变量时,调用newString()会在堆内存中创建一个String对象,String对象中的char数组会引用常量池中的字符串。调用intern方法后,会去常量池中查找是否有等于string对象的引用,有则返回引用。在创建b变量时,调用newString()会在堆内存中创建一个String对象,String对象中的char数组会引用常量池中的字符串。调用intern方法后,会去常量池中查找是否有等于string对象的引用,有则返回引用。而堆内存中的这两个对象将被垃圾回收,因为没有指向它们的引用。所以a和b指的是同一个对象。如果你在运行时创建一个字符串对象,它会直接在堆内存中创建,而不是在常量池中。因此,动态创建的字符串对象调用了intern方法。在JDK1.6版本中,会去常量池创建运行时常量,返回字符串引用。在JDK1.7版本之后,堆中对字符串常量的引用会被放入常量池中。当其他堆中的string对象通过intern方法获取到string对象引用时,会去常量池判断是否有对string的同值引用。如果有,则返回池中的常量A字符串引用,一个指向与前一个字符串相同地址的字符串对象。用一张图总结一下String字符串的内存地址的创建和分配:使用intern方法需要注意的一点是一定要结合实际场景。因为常量池的实现类似于HashTable的实现,HashTable中存储的数据越大,遍历的时间复杂度就越大。如果数据太大,会增加整个字符串常量池的负担。3.如何使用字符串拆分方法?Split()方法使用正则表达式来实现其强大的拆分功能,但是正则表达式的性能非常不稳定。使用不当会造成回溯问题,可能导致CPU居高不下。所以我们要谨慎使用Split()方法,我们可以使用String.indexOf()方法代替Split()方法来完成字符串的拆分。如果实在不能满足需求,可以在使用split()方法时注意回溯问题。6.总结我们认识到优化String字符串的性能可以提高系统的整体性能。基于这个理论,Java版本通过在迭代过程中不断改变成员变量来优化String对象,以节省内存空间。我们还特别提到了String对象的不变性。正是这个特性实现了字符串常量池,通过减少重复创建具有相同值的字符串对象,进一步节省了内存。但是也因为这个特性,我们在做长字符串拼接的时候,需要显式的使用StringBuilder来提高字符串拼接的性能。最后,在优化方面,我们还可以使用intern方法,让可变字符串对象可以复用常量池中相同值的对象,从而节省内存。最后,我想分享一点个人看法。那是千里之堤,崩于蚁穴。在日常的编程中,我们往往可能对一个小小的字符串没有深入的了解,使用不当,就可能造成线上事故。比如在我之前的工作经历中,使用正则表达式来匹配字符串造成并发瓶颈,也可以概括为字符串使用中的性能问题。【本文为栏目组织《AiChinaTech》原创文章,微信公众号(id:tech-AI)》】点此查看作者更多好文
