JavaSE(二)常用对象API—String相关

目录

常用对象API(一)

    String类

    StringBuffer&&StringBuilder

后话


 

常用对象API(一)

    String类

  String类代表字符串,是我们在编写代码中经常大量用到的一个类。它其实是一个非常特殊的类,正常情况下,想要创建一个类的对象需要用new关键字,而String类可以直接通过字面值(如"abc")直接实现该类。它是一个非常典型的Immutable类,即不可变类,其被声明成final class。

  在这里不打算讲String类中的各种操作方法的使用方式,只讲String类中涉及到的一些细节。

  那么先来看一段代码,观察其两者结果是否一样:

    public static void main(String[] args) {
        String str = "abc";
        String str1 = "abc";
        System.out.println(str == str1);

        String str2 = new String("abc");
        System.out.println(str == str2);
    }

  我们会发现第一个输出结果为true,而第二个输出结果为false。这又是为什么呢?

  首先先讲第一个结果的原因,当我们使用字面量创建字符串常量时,JVM会先到字符串常量池内寻找该字符串,如果该字符串已经存在于字符串常量池,那么就将此字符串对象的地址赋值给引用变量(例如str);如果字符串不存在常量池中,就会实例化该字符串并且将其放入常量池中,再将此字符串对象的地址赋值给引用变量。

  这里用一个内存示意图来加深理解:

  第二个结果又为什么和第一个结果截然不同呢?当我们使用new关键字来创建字符串常量时,JVM会先到字符串常量池寻找该字符串,如果该字符串已经存在于字符串常量池,那么就直接从常量池中复制一个字符串对象副本到堆中,再将堆内的字符串对象地址赋给引用变量;如果该字符串不存在常量池中,就会先实例化该字符串并将其放入常量池中,再复制该字符串对象的副本到堆中,最后将堆内的字符串对象地址赋给引用变量。

  我们这里还是用一个图来表示这一大串的语言:

  从这个图来看,我们可以说:“堆内创建一个String对象,对象构造时传参,产生了两个对象,一个是堆对象,一个是字符串”。一个是直接指向了字符串常量池地址,一个是指向了堆中的字符串对象地址,那么怎么可能会是相同的地址呢。

  知道了JVM是如何处理String类后,我们就得开始聊聊String类的不可变性。查看一下String类的源码(这里以JDK1.6为例),我们不难发现,String类是用final修饰的,并且所有的成员方法都默认为final方法,这就说明了String类无法被继承,也就无法破坏String类原有的结构。

  String类中有四个成员属性,如下所示:

    // JDK1.6
    /*存储字符串*/
    private final char value[];

    /*字符串第一个字符的索引*/
    private final int offset;

    /*字符串中的字符数*/
    private final int count;

    /*字符串的哈希码值*/
    private int hash; // Default to 0

  需要注意的是字符数组value,其对接字符串常量池。在JDK1.7以后已经没有offset和count属性了。但其实重点并不在这里,我们看一下concat方法的源码

    public String concat(String str) {
        int otherLen = str.length();
        if (otherLen == 0) {
            return this;
        }
        char buf[] = new char[count + otherLen];
        getChars(0, count, buf, 0);
        str.getChars(0, otherLen, buf, count);
        return new String(0, count + otherLen, buf);
    }

  再查看其他操作字符串的方法,不难发现,最后返回的都是一个新的String对象。也就是说,相关操作的效率会影响性能。比如说,统计一个字符串中出现"abcd"的次数。

  首先提出两个思路,第一个思路是通过indexOf以及subString方法来截取不同子串分别再判断。第二个思路是只通过indexOf来对主串一直循环的遍历来统计次数。

  我们先顺着第一个思路,前面说过,操作字符串的方法的结果最后都是返回一个新的字符串。那么,当我们需要获取一个非常长的字符串时某一个子串出现的次数,因为这样的设计会导致内存中存储过多不必要的字符串,因此我们需要偏向第二个思路。

  因此,第二个思路只需要利用index+子串的长度来控制索引,代码如下所示:

    public static void main(String[] args) {
        String str = "abcvufabcdasjoabcdvrojiwabcd";
        String key = "abcd";
        int index = 0;
        int count = 0;
        while( (index=str.indexOf(key, index))  != -1) {
            index = index + key.length();
            count++;
        }
        System.out.println(count);
    }

  接下来讲通过"+"运算符来创建字符串,通过一段代码来讲解:

    public static void main(String[] args) {
        // 都是字面量时
        String str = "hello" + "world";
        // 其中一个变量
        String str1 = "str" + new String("_string");
        // 两个都是变量
        String str2 = new String("Hello") + new String("world");
    }

  通过javap -c命令将编译后的字节码文件反编译,我们会发现编译器对不同情况的下"+"运算符有不同的处理。

  首先是第一种都是字面量时,编译器会将字符串直接拼接,然后在字符串常量池中会保存拼接好的字符串,即当编译器运行到第一句时,只会创建一个对象。如下所示:

       0: ldc           #16                 // String helloworld
       2: astore_1

       /*
            将常量"helloworld"压入数据操作栈
            然后再将操作栈中的该常量pop,将其赋值给局部变量表中的str。
        */

  即代码会优化成如下:

        String str = "helloworld";

  当其中有一个是变量,非静态的拼接逻辑会自动被转换为StringBuilder操作,通过StringBuilder来完成拼接。

       3: new           #18                 // class java/lang/StringBuilder
       6: dup
       7: ldc           #20                 // String str
       9: invokespecial #22                 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
      12: new           #25                 // class java/lang/String
      15: dup
      16: ldc           #27                 // String _string
      18: invokespecial #29                 // Method java/lang/String."<init>":(Ljava/lang/String;)V
      21: invokevirtual #30                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      24: invokevirtual #34                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      27: astore_2

  这段的含义就是先创建一个StringBuilder对象并压入操作数栈,dup是指将操作数栈里的数据复制一份并且压入栈,ldc就是指将常量池中的数据压入操作数栈,最后invokespecial是让其出栈,调用其构造函数完成初始化。接着再创建一个String对象,并且通过StringBuilder里的append方法将这个对象连接起来,最后用toString方法完成转换。

  转换成Java代码其实就是如下所示:

        String str1 = (new StringBuilder("str")).append(new String("_string")).toString();

  那么这段代码到底有多少个对象生成呢?这里默认字面量字符串没有在常量池中。两个字面量对象加上堆内的两个String对象以及StringBuilder对象,总共五个。需要注意的是,在源码中可以看到append以及toString并没有生成新的字符串,即如下所示:

    // StringBuilder类
    public StringBuilder append(String str) {
        super.append(str);
        return this;
    }

    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }

    // StringBuilder类的父类AbstractStringBuilde
    public AbstractStringBuilder append(String str) {
        if (str == null) str = "null";
            int len = str.length();
        if (len == 0) return this;
            int newCount = count + len;
        if (newCount > value.length)
            expandCapacity(newCount);
        str.getChars(0, len, value, count);
        count = newCount;
        return this;
    }

    // String类
    public static String valueOf(Object obj) {
        return (obj == null) ? "null" : obj.toString();
    }

  说到这里,其实最后的两个String类对象拼接也不外乎就是将String类对象转换成了StringBuilder之后再用append方法拼接。即代码如下所示:

String str2 = (new StringBuilder(String.valueOf(new String("Hello")))).append(new String("world")).toString();

  最后我们再来讲讲JDK6出现的一个intern()方法,目的是将调用该方法相应的字符串缓存起来,以便重复使用。当我们创建字符串对象并且调用intern()方法时,如果字符串常量池中已有该字符串,就会返回常量池中的实例,否则就将其添加至常量池中

  那为什么会提到这个方法呢,首先是为了提出字符串缓存机制,其次是因为该方法在JDK6其实并不适用,原因在于JDK6时,常量池其实是在PermGen,即称为“永久代”的空间。这个空间的大小特别有限,其次也不会被Full GC以外的垃圾收集特别注意到。但是Full GC发送的次数又不是很频繁,就会导致产生空间的浪费以及效率低下问题。而在JDK7以后,常量池被放进堆内,这样就避免了永久代空间不足的问题,甚至在JDK8后,永久代直接消失由元数据区代替。

  上面所说的作为了解即可,其中涉及到的一些名词可以自己翻阅文档以及找一些有关JVM的文章来看。

  这个intern()方法其实是一种显式地排重机制,也就是说必须要开发者自己明确调用该方法,而且效率不一定能够保证,因为开发阶段并不能预估到字符串的重复使用情况。那么其实在JDK8u20以后,推出了一个新特性来处理这个情况。未来有机会再写一篇文章描述一下JDK8到底有哪些更新,这里不再对其过多讲述,了解即可。

  接下来演示一下JDK6以及JDK7中intern的区别,先附上一个代码:

    public static void main(String[] args) {
        // 其中一个是String对象
        String str = "str" + new String("_string");
		
        System.out.println(str == str.intern());
    }

  在JDK6运行的结果是false,而JDK7运行的结果则是true。为什么会有这样截然不同的结果呢?其实问题就出现在常量池在永久代而不是在堆里,前面说过,如果字符串常量池中已有该字符串,就会返回常量池中的实例。可问题是,这个字符串str并不存在常量池中而是存在堆中。就说明str和intern非同一个对象。

  还是用幅图来表示原因吧。

    StringBuffer&&StringBuilder

  前面说过,字符串操作具有普遍性,并且由于它的不变形导致了对字符串的操作最终都会产生新的String对象。为了解决拼接中产生太多中间对象的问题而提供了一个StringBuffer类,在这个类里提供了append和insert方法来把字符串添加到已有的字符串的末尾或者指定位置。但是这个类随之带来了额外的性能开销,因为StringBuffer本质上是一个线程安全的可变字符序列。因此在JDK5又出现了一个新类StringBuilder。

  StringBuilder在功能上和StringBuffer没有区别,值得一提的是,StringBuffer和StingBuilder都有一个共同的抽象类AbstractStringBuilder。StringBuilder只是去除了线程安全的部分,有效地减小了性能的开销。

  查看了StringBuffer以及StringBuilder的源码,不难发现,为了实现可变字符序列,在其父类定义了可修改的数组,即没有final修饰的数组,如下所示:

abstract class AbstractStringBuilder implements Appendable, CharSequence {

    char value[];

    /*其他代码*/
}

  我们可以认为AbstractStringBuilder是一个容器,那么容器需要多大才能完成操作?

  目前默认的情况是,调用有参构造函数时是初始的字符串长度加16,这也说明了无参时,默认数组大小为16。如果字符串拼接时过多,则会将该容器扩容,但是扩容操作实际上会增加性能的开销。因为扩容要抛弃原有的数组并且创建一个新的更大的数组还要让系统进行arraycopy操作。所以如果可以预估字符串的拼接次数,那么就可以指定合适的大小。

  其实最大的问题并不在拼接,而是在数组类型。通过查看JDK1.6-1.8,我们会发现String类以及AbstractStringBuilder类都是用的char类型的数组,但是在Java中char类型是两个字节的,而那些字母之类的根本就不需要这么宽的char,这样就会导致空间的浪费问题出现。而在JDK9中将数据存储方式从char数组变成了一个byte数组加上一个标识编码coder,即如下所示:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {

    @Stable
    private final byte[] value;

    private final byte coder;
}

  这个coder是一个编码格式的标识符,即使用使用LATIN1还是UTF-16,如果字符串里的内容用LATIN1都能表示则为0,否则为1。

  因为更改了相应的存储类型,在很多方法也产生了变化。比如说获取字符串长度方面,

    // JDK9以下的版本
    public int length() {
        return value.length;
    }

    // JDK9
    public int length() {
        return value.length >> coder();
    }

  右移多少位则是根据编码格式决定的,如果是LATIN-1就是右移0位,如果是UTF-16就右移1位,这样就能返回正确的字符串长度。

  差不多字符串大致的内容就到这结束了,对字符串的拼接再额外提一句,在JDK8中会被自动转换为StringBuilder操作,而JDK9利用了invokeDynamic将字符串拼接的操作进一步优化。

后话

    '''
        字符串是我们在开发中经常会操作的数据,而底层的优化是至关重要的
        从JDK版本对String相关类的演变也能看出这一点
        小小的东西更需要化功夫去了解
    '''

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值