String对象是我们日常使用的对象类型,字符串对象或者其等价对象(如char数组),在内存中总是占据了最大的空间块,因此如何高效地处理字符串,是提高系统整体性能的关键。
在此之前,String作为一个对象类型,我们必须清楚Java对象的创建以为对象的内存结构。
创建一个对象通常需要使用new关键字,当虚拟机遇到一条new指令的时候,首先会检查这个指令的参数是在常量池中定位到一个符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。如果是则执行相应的类加载过程。
类加载检查结束之后,虚拟机将为新生对象分配内存,java中为对象分配内存有两种方式,一种是,该方法适用于内存规整的情况,在中间放一个指针作为分界点的指示器,使用过的内存和空闲的内存各放在一边,当需要分配内存的时候只需要将指针移动即可。另一种是,如果java堆中的内存不是规整的,虚拟机会维护一张列表,记录哪块内存可用,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。采用哪种分配方式是根据java堆是否规整决定的。而java堆是否规整由JVM是否使用带有压缩整理功能的垃圾收集器决定。
另外需要考虑的是内存分配过程中线程安全的情况。有如下两种解决方案;
- 堆内存分配的动作做同步处理。
- 另一种是把内存分配的动作按照线程划分为不同的空间之中执行,即每一个线程在java堆中预先分配一小块内存(TLAB),哪个线程需要分配内存首先在TLAB上分配,如果TLAB分配完了之后,才会同步分配新的TLAB。JVM是否使用TLAB由参数来决定。
内存分配完毕之后想,虚拟机需要分配到的内存空间初始化为零值。这一步操作保证了对象的实例字段在java代码中可以不赋初始值就可以使用,接下来虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息等,这些信息存放在对象的对象头中。这些工作完成之后,从JVM的角度来看一个对象已经创建成功了,从java的角度来看还需要执行init方法,将对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
在HotSpot虚拟机中,对象在内存中存储的布局可分为三个部分,即对象头,实例数据和对齐填充。
对象头包括两个部分,第一部分用来存储对象自身运行时的数据,如哈希码,GC分代年龄、线程所持有的锁等,官方称为“Mark Word”。第二个部分为类型 指针,即对象指向它的类元数据的指针,虚拟机通过这个 指针来确定这个对象属于哪个类的实例。
实例数据是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容。
对齐填充并不是必须的,仅仅起到占位符的作用,HotSpot虚拟机需要对象起始地址必须是8字节的整数倍,对象部分正好是8字节的整数倍,所以当实例数据部分没有对齐时,需要通过对齐填充来对齐。
对于String类型,我们首先来看看其JDK内部的成员变量的声明代码:
我们会看到它内部维护着一个char数组,而且它是由final关键词修饰的,说明它一旦创建之后不可变。对于String的创建,比较特殊一些,我们来看一下它的具体创建原理:
- 不管使用任何方式来创建一个字符串S的时候,Java运行时会拿着这个S字符串在String池中查找是否存在内容相同的字符串对象,如果不存在,则在池中创建一个字符串S,否则不会创建对象,也不会在池中添加。
- 前面提到使用new关键创建对象,那么肯定会在堆栈创建一个新的对象,String也是一样的。
- 使用直接指定或者使用纯字符串拼接来创建String对象,则仅仅会检查String池中的字符串,池中没有就创建一个,如果存在,就不需要创建新的,但是绝对不会在堆栈区再去创建对象。
- 使用包含变量的表达式来创建String对象时,则不仅会检查并维护Sting池,而且还会在堆栈区创建一个新的String对象。
最常见的String操作莫过于拼接字符串了,在拼接字符串时,我们尽量用+,因为通常编译器会做出优化,如String test="hello "+"world",编译器会将其视为String test="hello world"。所以在拼接国泰字符串时,我们需要尽量使用StringBuffer或者StringBuilder的append方法,这样可以减少构造过多的临时String对象。下面我们来看一个简单的实例来证实:
在String对象中有一个特殊的方法,它是一个本地方法,当调用该方法时,如果池中已经包含了一个等于此String对象的字符串,则返回池中的字符串,否则,将此对象添加到池中,并且返回String对象的引用。
在上面的一个例子中,str1和str4并不是同一个对象引用,因此不相等,那么我们使用intern方法,添加一句,观察运行结果:
sdsds.png
也许很多人想到我们可以使用intern方法来创建对象,避免使用new创建大量的对象,但是这也有一个隐含的问题。
使用String的方法返回JVM对字符串缓存池里已经存在的字符串引用,从而解决内存性能问题,但是intern方法使用的池是JVM全局的池,很多情况下我们的程序并不需要如此大作用域的缓存,而且,它所使用的是JVM heap中PermGen对应的区域,PermGen通常是用来存放装载类和创建类实例时用到的元数据,因此,使用过多的intern方法会导致PermGen过度增长而最后返回OOM,因此垃圾收集器不会对缓存的String做垃圾回收,因此不建议使用。
实际中,如果需要创建大量的字符串,我们可以自己构建缓存,比如使用HashMap,将需缓存的String作为key和value放在HashMap中,例如下面代码:
public String getCacheString(String key){
String temp=cacheMap.get(key);
if(temp!=null){
return temp;
}else{
cacheMap.put(key,key);
return key;
}
}
在字符串的使用中,另一个常见的操作是截取字符串,在String内部提供了方法供我们使用,其源码如下(1.8版本):
从上面的源码可以看出,substring方法截取字符串的时候,会将String的原生内容复制到新的子字符串中,从整个方法的调用链来看,它会保存原始String。因此这也引发了下面的问题。
- 在一个大字符串中我们需要截取的字符串远远小于其原始字符串的长度时,不建议直接使用substring方法截取后直接返回,这样会造成内存泄漏,我们可以使用new String的方式来创建一个个字符串对象,将垃圾回收交给JVM GC,避免内存泄漏问题。
- 当在一个大字符串中我们需要截取的字符串几乎和原始字符串长度相等的时候,我们可以放心的使用substring方法来截取返回。
所幸的是,在JDK1.7之后的版本中,将substring的内部实现修改为使用Arrays进行拷贝,不再复用之前的原字符串,因此使其得以回收,所以String内存泄漏的问题也得到了修复。
如果使用了1.7之前的API,也可以使用下面的方法来解决内存泄漏问题。
看一个用例:
public class TestSubString {
public static void main(String[] args) {
List<String> list=new ArrayList<String>();
for(int i=0;i<1000;i++){
SubString1 str1=new SubString1();
SubString2 str2=new SubString2();
list.add(str1.getSubString(1,6));
list.add(str2.getSubString(1,6));
}
}
public static class SubString1{
public String str=new String(new char[10000000]);
public String getSubString(int begin,int end){
return new String(str.substring(begin, end)); //使用new重新创建字符串
}
}
public static class SubString2{
public String str=new String(new char[10000000]);
public String getSubString(int begin,int end){
return str.substring(begin, end); //直接截取返回
}
}
}
在这个用例中,原始字符串很大,但是需要截取的却是很小的一段,因此在这种场景下推荐使用SubString1重新new一个字符串来释放原始字符串的方式来截取字符串,这样避免了原始字符串不能被回收,存在内存泄漏的问题
网友评论