侧边栏壁纸
  • 累计撰写 98 篇文章
  • 累计创建 20 个标签
  • 累计收到 3 条评论

String原理

林贤钦
2020-05-26 / 0 评论 / 13 点赞 / 685 阅读 / 0 字
温馨提示:
本文最后更新于 2020-05-26,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

String原理

从概念上讲, Java 字符串就是 Unicode 字符序列,Java 没有内置的字符串类型, 而是在标准 Java 类库中提供了一个预定义类,很自然地叫做 String。每个用双引号括起来的字符串都是String类的一个实例

String部分源码

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    //char数组用于储存String的内容
    private final char value[];

    //String实例化的hashcode的一个缓存,String的哈希码被频繁使用,将其缓存起来,
    //每次使用就没必要再次去计算,这也是一种性能优化的手段。
    private int hash; // Default to 0

    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;

从上面可以看出

  1. String类被final关键字修饰,意味着String类不能被继承,并且它的成员方法都默认为final方法;字符串一旦创建就不能再修改。
  2. String类实现了Serializable、CharSequence、 Comparable接口。
  3. String实例的值是通过字符数组实现字符串存储的。

1、序列化接口

//String a = new String("123");
String a = "123";
ByteArrayOutputStream bos = null;
ObjectOutputStream oos = null;
ByteArrayInputStream bis = null;
ObjectInputStream ois = null;
try {
    // 序列化
    bos = new ByteArrayOutputStream();
    oos = new ObjectOutputStream(bos);
    oos.writeObject(a);
    // 反序列化
    bis = new ByteArrayInputStream(bos.toByteArray());
    ois = new ObjectInputStream(bis);
    String o = (String) ois.readObject();
    System.out.println(o);
} catch (Exception e) {
    e.printStackTrace();
    System.out.println(e);
} finally {
    // 关闭流
    try {
        bos.close();
        oos.close();
        bis.close();
        ois.close();
    } catch (Exception e2) {
        System.out.println(e2.getMessage());
    }
}

结果显而易见,无论是通过new创建还是直接String xx = "xxx";都支持序列化


2、“+”连接符

“+”连接符的实现原理

Java语言为“+”连接符以及对象转换为字符串提供了特殊的支持,字符串对象可以使用“+”连接其他对象。其中字符串连接是通过StringBuilder(或 StringBuffer)类及其append 方法实现的,对象转换为字符串是通过 toString 方法实现的,该方法由 Object 类定义,并可被 Java 中的所有类继承

在jdk文档可以看到这个,我们都知道String是不可变的,那么他的拼接,定然不会改变它原来的对象

举个例子debug一下

public class TestString {
  public static void main(String[] args) throws Exception {
    String a = "123";
    String b= a+"456";
    String c= a+b;
  }
}
public final class StringBuilder
    extends AbstractStringBuilder
    implements java.io.Serializable, CharSequence
{
    @Override
    public StringBuilder append(String str) {
        super.append(str);
        return this;
    }
}

可以看到String c = a+b; 是由StringBuilder 的append实现的

我们在点进去StringBuilder的super.append(str)

public AbstractStringBuilder append(String str) {
    if (str == null)
        return appendNull();
    int len = str.length();
    ensureCapacityInternal(count + len);
    str.getChars(0, len, value, count);
    count += len;
    return this;
}

也就是证实了,String,StringBuilder的底层就是用字符数组来操作String字符串的


再举个例子从字节码字节码角度

/**
 * 测试代码
 */
public class Test {
    public static void main(String[] args) {
        int i = 10;
        String s = "abc";
        System.out.println(s + i);
    }
}

/**
 * 反编译后
 */
public class Test {
    public static void main(String args[]) {    //删除了默认构造函数和字节码
        byte byte0 = 10;      
        String s = "abc";      
        System.out.println((new StringBuilder()).append(s).append(byte0).toString());
    }
}

Java中使用"+"连接字符串对象时,会创建一个StringBuilder()对象,并调用append()方法将数据拼接,最后调用toString()方法返回拼接好的字符串

由于append()方法的各种重载形式会调用String.valueOf方法,所以我们可以认为:

//以下两者是等价的
s = i + ""
s = String.valueOf(i);
 
//以下两者也是等价的
s = "abc" + i;
s = new StringBuilder("abc").append(i).toString();

“+”连接符的效率

使用“+”连接符时,JVM会隐式创建StringBuilder对象,这种方式在大部分情况下并不会造成效率的损失,不过在进行大量循环拼接字符串时则需要注意。

String s = "abc";
for (int i=0; i<10000; i++) {
    s += "abc";
}

/**
 * 反编译后
 */
String s = "abc";
for(int i = 0; i < 1000; i++) {
    s = (new StringBuilder()).append(s).append("abc").toString();    
}

这样就导致大量的StringBuilder创建在堆内存中,肯定会造成效率的损失

在这种情况下建议在循环体外创建一个StringBuilder对象调用append()方法手动拼接

/**
 * 循环中使用StringBuilder代替“+”连接符
 */
StringBuilder sb = new StringBuilder("abc");
for (int i = 0; i < 1000; i++) {
    sb.append("abc");
}
sb.toString();

当然,也有特殊情况

  1. 也就是当"+"两端均为编译期确定的字符串常量时,编译器会进行相应的优化,直接将两个字符串常量拼接好

    System.out.println("Hello" + "World");
    
    /**
     * 反编译后
     */
    System.out.println("HelloWorld");
    
  2. 对于final修饰的变量,它在编译时被解析为常量值的一个本地拷贝存储到自己的常量池中或嵌入到它的字节码流中。

    String s0 = "ab"; 
    final String s1 = "b"; 
    String s2 = "a" + s1;  
    System.out.println((s0 == s2)); //result = true
    
  3. 这里面虽然将s1用final修饰了,但是由于其赋值是通过方法调用返回的,那么它的值只能在运行期间确定

    String s0 = "ab"; 
    final String s1 = getS1(); 
    String s2 = "a" + s1; 
    System.out.println((s0 == s2)); //result = false 
    
    public String getS1() {  
        return "b";   
    }
    

综上,“+”连接符对于直接相加的字符串常量效率很高,因为在编译期间便确定了它的值,也就是说形如"I"+“love”+“java”; 的字符串相加,在编译期间便被优化成了"Ilovejava"。

对于间接相加(即包含字符串引用,且编译期无法确定值的),形如s1+s2+s3; 效率要比直接相加低,因为在编译器不会对引用变量进行优化。


3、字符串常量池

字符串的分配和其他对象分配一样,是需要消耗高昂的时间和空间的,而且字符串使用的非常多。JVM为了提高性能和减少内存的开销,在实例化字符串的时候进行了一些优化:使用字符串常量池

字符串创建过程

每当创建字符串常量时,JVM会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就直接返回常量池中的实例引用。如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中。

由于String字符串的不可变性,常量池中一定不存在两个相同的字符串。也就是说字符的改变,实际上是改变字符串的引用,并不是改变引用的内容

String a = "123";
String b=  "123";
System.out.println(a==b);//true

内存区域

HotSpot VM中字符串常量池是通过一个StringTable类实现的,它是一个HashTable表,默认值大小长度是1009;StringTable在每个HotSpot VM的实例中只有一份,被所有的类共享;字符串常量由一个一个字符组成,放在了StringTable上。

注意: 如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern时性能会大幅下降(因为要一个一个找)。

  • 在 jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。

  • 在jdk7中,StringTable的长度可以通过一个参数指定:

    -XX:StringTableSize=99991

至于JDK7为什么把常量池移动到堆上实现,原因可能是由于方法区的内存空间太小且不方便扩展,而堆的内存空间比较大且扩展方便。

存放的内容

  • 在JDK6及之前版本中,String Pool里放的都是字符串常量;

  • 在JDK7.0中,由于String.intern()发生了改变,因此String Pool中也可以存放放于堆内的字符串对象的引用。

String a = "123";
String b=  "123";
String c = new String("123");
System.out.println(a==b);//true
System.out.println(b==c);//false

分析

  1. String a = "123";在字符串"123",创建的时候,jvm会找StringTable(串池),是否存在字符串”abc“,显然这里不存在的,jvm就在串池中添加字符串"123",然后将引用地址赋值给String对象a
  2. 当String b= "123";的时候,这时候串池已经存在字符串"123",jvm会直接将"123"的引用地址赋值给对象b,所以a==b为true
  3. 当String c = new String("123");我们都知道,通过new创建的对象都是保存在堆中,所以这里也不例外,堆会保存c对象的内容,包括字符串"123",然后将引用地址赋值给c,所以,c==b 为false

如图

4、intern方法

来看一段代码:

public static void main(String[] args) {
    String s = new String("1");
    s.intern();
    String s2 = "1";
    System.out.println(s == s2);

    String s3 = new String("1") + new String("1");
    s3.intern();
    String s4 = "11";
    System.out.println(s3 == s4);
}
  • jdk6 下false false
  • jdk7 下false true

然后将s3.intern();语句下调一行,放到String s4 = "11";后面。将s.intern(); 放到String s2 = "1";后面。

public static void main(String[] args) {
    String s = new String("1");
    String s2 = "1";
    s.intern();
    System.out.println(s == s2);

    String s3 = new String("1") + new String("1");
    String s4 = "11";
    s3.intern();
    System.out.println(s3 == s4);
}
  • jdk6 下false false
  • jdk7 下false false

分析

jdk6中的解释

如上图所示。首先说一下 jdk6中的情况,在 jdk6中上述的所有打印都是 false 的,因为 jdk6中的常量池是放在 Perm 区中的,Perm 区和正常的 JAVA Heap 区域是完全分开的。上面说过如果是使用引号声明的字符串都是会直接在字符串常量池中生成,而 new 出来的 String 对象是放在 JAVA Heap 区域。所以拿一个 JAVA Heap 区域的对象地址和字符串常量池的对象地址进行比较肯定是不相同的,即使调用String.intern方法也是没有任何关系的。

jdk7中的解释

在 Jdk6 以及以前的版本中,字符串的常量池是放在堆的 Perm 区的,Perm 区是一个类静态的区域,主要存储一些加载类的信息,常量池,方法片段等内容,默认大小只有4m,一旦常量池中大量使用 intern 是会直接产生java.lang.OutOfMemoryError: PermGen space错误的。 所以在 jdk7 的版本中,字符串常量池已经从 Perm 区移到正常的 Java Heap 区域了。

  • 在第一段代码中,先看 s3和s4字符串。String s3 = new String("1") + new String("1");,这句代码中现在生成了2最终个对象,是字符串常量池中的“1” 和 JAVA Heap 中的 s3引用指向的对象。中间还有2个匿名的new String("1")我们不去讨论它们。此时s3引用对象内容是”11”,但此时常量池中是没有 “11”对象的。
  • 接下来s3.intern();这一句代码,是将 s3中的“11”字符串放入 String 常量池中,因为此时常量池中不存在“11”字符串,因此常规做法是跟 jdk6 图中表示的那样,在常量池中生成一个 “11” 的对象,关键点是 jdk7 中常量池不在 Perm 区域了,这块做了调整。常量池中不需要再存储一份对象了,可以直接存储堆中的引用。这份引用指向 s3 引用的对象。 也就是说引用地址是相同的。
  • 最后String s4 = "11"; 这句代码中”11”是显示声明的,因此会直接去常量池中创建,创建的时候发现已经有这个对象了,此时也就是指向 s3 引用对象的一个引用。所以 s4 引用就指向和 s3 一样了。因此最后的比较 s3 == s4 是 true。
  • 再看 s 和 s2 对象。 String s = new String("1"); 第一句代码,生成了2个对象。常量池中的“1” 和 JAVA Heap 中的字符串对象。s.intern(); 这一句是 s 对象去常量池中寻找后发现 “1” 已经在常量池里了。
  • 接下来String s2 = "1"; 这句代码是生成一个 s2的引用指向常量池中的“1”对象。 结果就是 s 和 s2 的引用地址明显不同。图中画的很清晰。

  • 来看第二段代码,从上边第二幅图中观察。第一段代码和第二段代码的改变就是 s3.intern(); 的顺序是放在String s4 = "11";后了。这样,首先执行String s4 = "11";声明 s4 的时候常量池中是不存在“11”对象的,执行完毕后,“11“对象是 s4 声明产生的新对象。然后再执行s3.intern();时,常量池中“11”对象已经存在了,因此 s3 和 s4 的引用是不同的。
  • 第二段代码中的 s 和 s2 代码中,s.intern();,这一句往后放也不会有什么影响了,因为对象池中在执行第一句代码String s = new String("1");的时候已经生成“1”对象了。下边的s2声明都是直接从常量池中取地址引用的。 s 和 s2 的引用地址是不会相等的。

intern 正确使用例子

static final int MAX = 1000 * 10000;
static final String[] arr = new String[MAX];

public static void main(String[] args) throws Exception {
    Integer[] DB_DATA = new Integer[10];
    Random random = new Random(10 * 10000);
    for (int i = 0; i < DB_DATA.length; i++) {
        DB_DATA[i] = random.nextInt();
    }
	long t = System.currentTimeMillis();
    for (int i = 0; i < MAX; i++) {
        //arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length]));
         arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();
    }

	System.out.println((System.currentTimeMillis() - t) + "ms");
    System.gc();
}

5、常用方法

  • int length():获取长度

    String s = "We are students";
    int len=s.length();//15
    
  • char charAt(int index);根据位置获取位置上某个字符。

    String s = "We are students";
    char c = s.charAt(4);//r
    
  • int indexOf(int ch):返回的是ch在字符串中第一次出现的位置。

    String s = "We are students";
    int num = s.indexOf("s");//7
    
  • int indexOf(int ch,int fromIndex):从fromIndex指定位置开始,获取ch在字符串中出现的位置。

    String s = "We are students";
    int a = s.indexOf('s', 2);//7
    
  • int indexOf(String str):返回的是str在字符串中第一次出现的位置。

     String s = "We are students";
     int i = s.indexOf("s");//7
    
  • int indexOf(String str,int fromIndex):从fromIndex指定位置开始,获取str在字符串中出现的位置。

    String s = "We are students";
    int i = s.indexOf("s",2);//7
    
  • int lastIndexOf(String str):反向索引。

    String s = "We are students";
    int a = s.lastIndexOf("a");//3
    
  • boolean contains(str);字符串中是否包含某一个子串

    String s = "We are students";
    boolean e = s.contains("e");/true
    
  • boolean isEmpty():原理就是判断长度是否为0。

  • 10、boolean startsWith(str);字符串是否以指定内容开头。

    String s = "We are students  ";
    boolean b = s.startsWith("We");//true
    
  • 11、boolean endsWith(str);字符串是否以指定内容结尾。

  • 12、boolean equals(str);判断字符内容是否相同

  • 13、boolean.equalsIgnorecase();判断内容是否相同,并忽略大小写。

  • 14、String trim();将字符串两端的多个空格去除

  • 15、int compareTo(string);对两个字符串进行自然顺序的比较

  • 16、String toUpperCsae() 大转小 String toLowerCsae() 小转大

  • 17、 String subString(begin);

  • 18、String subString(begin,end);获取字符串中子串

    String s = "We are students  ";
    String substring = s.substring(4, 7);//re
    
  • 19、String replace(oldchar,newchar);将字符串指定字符替换。

    String s = "We are students  ";
    String replace = s.replace('a', 'b');//We bre students  
    

6、String、StringBuilder和StringBuffer

区别

  1. String是不可变字符序列,StringBuilder和StringBuffer是可变字符序列。
  2. 执行速度StringBuilder > StringBuffer > String。
  3. StringBuilder是非线程安全的,StringBuffer是线程安全的。

线程安全性

  • StringBuilder(非线程安全)

    StringBuilder的方法没有该关键字修饰,所以不能保证线程安全性。

  • StringBuffer(线程安全的)

    StringBuffer中大部分方法由synchronized关键字修饰,在必要时可对方法进行同步,因此任意特定实例上的所有操作就好像是以串行顺序发生的,该顺序与所涉及的每个线程进行的方法调用顺序一致,所以是线程安全的。

总结

  • String:适用于少量的字符串操作。

  • StringBuilder:适用于单线程下在字符串缓冲区进行大量操作。

  • StringBuffer:适用于多线程下在字符串缓冲区进行大量操作。

13

评论区