Java学习 -- StringStringBufferStringBuilder

Posted 庸人冲

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java学习 -- StringStringBufferStringBuilder相关的知识,希望对你有一定的参考价值。

String

String概述

java.lang.String类代表字符串。

  1. Java 程序中的所有字符串字面值(如 “abc” )都作为此类的实例实现。
  2. String类声明为final, 不可被继承
  3. String内部定义了**private final char[] value**用于存储字符串数据(JDK8)。
  4. String类实现了Serializable接口:表示字符串是支持序列化的。
  5. String类实现了Comparable接口:表示String可以比较大小

String类的声明以及属性如下:

String实例化

String类的实例化可以分为字面量赋值和**new + 构造器()**两种分式。

字面量赋值

“”引起的字面量都做为String类的实例,可以直接赋值给String类的引用变量。

String str = "hello";

在Java中由“”引起的字面量在编译期会以CONSTANT_StringCONSTANT_Utf8的形式被保存**Class文件的常量池中,在类加载阶段这些字面量会进入当前类的运行时常量池**,当字面量第一被使用时(ldc指令推入栈顶),才会以字符串对象的形式在堆中创建对象,并在字符串常量池(StringTable)保存该对象的引用。

关于字面量进入字符串常量池的具体细节请参阅此处: Java 中new String(“字面量”) 中 “字面量” 是何时进入字符串常量池的?

上面的str中实际保存的是字符串常量池中的“hello”的引用。对此我们可以使用javap -verbose 指令进行查看。


构造器实例化

String类重载的构造器有很多,大概有十几种重载的构造器,Java的官方文档中可以查询到,这里不再列出。需要注意的是文档中标注Deprecated的构造器表示已经弃用,不建议使用。

对于这么多的构造器我们不需要全部了解,只需要掌握常用一些构造器即可,其它的构造器用到时,再去查阅文档。

常用构造器

String(String original)

该构造器中的参数为一个字符串字面量。

String str = new String("hello");

这种实例化方式与字面量直接赋值的区别在于:

  1. 字面量赋值String类引用变量会直接指向字符串常量池中的字符串。

  2. **String(String original)**实例化时,会先在堆空间中新建一个String类的对象,str会指向堆中新创建的这个String对象,这个对象的value属性中保存了常量池中字符串对象的value属性。

上述代码的反汇编:

String(String original)构造器中的操作:

String(char[] value)

该构造器中的参数为一个char[]类型数组,构造器会调用Arrays.copyOf()方法新建一个参数数组的拷贝,并将拷贝后的字符数组首元素地址赋值给this.value属性。

// 举例:
char[] chars = {'a', 'b', 'c'};
String str = new String(chars);
System.out.println(str);  // abc

通过这种方式实例化String类对象时,str 指向了堆中新建的String对象,这个对象的value属性指向了拷贝后的字符数组。

String(char[] value, int offset, int count)

该构造器会将参数数组中,从offset下标开始,长度为count的子字符数组拷贝至新字符串当中。

// 举例:
@Test
public void test() {
    char[] chars = {'a', 'b', 'c', 'd', 'e', 'f'};
    String str = new String(chars, 3, 3);
    System.out.println(str);  // def

    // 当指定的索引或者长度大于参数数组的最大索引时会抛出字符串索引越界异常
    str = new String(chars,3,4);
}


String类中常用的构造器还有许多,其它的构造器在后面用到时在进行介绍。

两种实例化方式的内存图

String注意点

字符串判断相等

对于字符串判断相等,不能使用==运算符,这个操作符只能用于判断两个字符串是否存放在同一个位置上,尽管存放在同一个位置上的字符串一定相等,但是内容相等的字符串也会存放在不同的位置上。

public void test(){
    String str1 = "hello";
    String str2 = new String("hello");
    System.out.println(str1 == str2)  // 输出结果为: false
}

在上文已经介绍过两种实例化方式的内存布局,此处不再赘述。

如果需要判断字符串是否相等,应该使用String类重写的**equals()方法**。

public void test(){
    String str1 = "hello";
    String str2 = new String("hello");
    System.out.println(str1.equals(str2));  // 输出结果为: true
}

String类重写equals()方法的内部实现:

对于一个字符串引用和一个字符串常量的比较,建议调用字符串常量的equals()方法,可以有效防止空指针异常。

public void test(){
    String str1 = new String("hello");
    System.out.println("hello".equals(str1));  // 输出结果为: true
}

字符串拼接

Java中字符串的拼接可以使用 ++= 运算符或者concat()方法。

++=运算符拼接字符串

Java编程思想中的描述,用于String++=Java中仅有的两个重载过的运算符。重载的意思是,当一个运算符用于特殊类时被赋予了特殊的意义。对于String类来说,++=运算符会将运算符左右的操作数进行拼接。在进行拼接时,实际上是利用了StringBuilder类对象进行字符串拼接。如果操作数是基本数据类型,会先将基本数据类型转换为所对应的字符数组。如果是引用数据类型则会调用该类的toString()方法进行拼接。

我们看下面的例子:

例1:

对于字面量的字符串拼接和引用变量的字符串拼接最终返回的引用是不同的。

    public static void main(String[] args) {
        String s1 = "ab";
        String s2 = "cd";
        String s3 = "ab" + "cd";
        String s4 = s1 + s2;
        System.out.println(s3);  // "abcd"
        System.out.println(s4);  // "abcd"
        System.out.println(s3 == s4);  // false
    }

从第6和第7行输出结果来看,s3和s4的内容相同。但是在判断两个引用的地址时输出结果为false,也就是说s3和s4指向的不是不一块内存空间。原因如下:

  1. 如果是字面量的方式进行拼接字符串,那么在编译期间就会进行对字面量进行运算并将新字符串的信息Class文件的常量池中,当运行时“abcd”以字符串形式放入字符串常量池中,s3接收的到的就是常量池中“abcd”字符串的引用。

  2. 而如果是涉及到变量的拼接字符串,那么在运行期间 +运算符实际上在底层创建了一个StringBuilder类的对象(后面会介绍StringBuilder类),并调用该对象的append()方法将字符串添加进对象中,然后再转换为一个String类的对象并返回该对象的引用。所以s4其实是指向了堆空间中新建的String类对象。

内存图如下:

这一块我进源码看了一下,里面太复杂了差点没绕出来。StringBuilderdappend()toString()方法最终都调用了nativeSystem.arraycopy()方法进行数组拷贝,这块应该属于JVM的内容了属实超纲了,如果有不对的地方还请大佬们指正。

例2:

如果使用final来修饰s1s2,那么结果又不一样:

    public static void main(String[] args) {
        final String s1 = "ab";
        final String s2 = "cd";
        String s3 = "ab" + "cd";
        String s4 = s1 + s2;
        System.out.println(s3);  // "abcd"
        System.out.println(s4);  // "abcd"
        System.out.println(s3 == s4);  // true
    }

从字节码文件可以看到,第4行和第5行所执行的操作相同。这里一块在查阅相关资料后得知应该是涉及到一个常量折叠的概念:

将常量表达式的值求出来作为常量嵌在最终生成的代码中,这种优化叫做常量折叠。 — 转自知乎RednaxelaFX大佬

大佬的文章中指出在Java中符合常量折叠的情况:

  1. 八大基本数据类型和String类的字面量。
  2. static final修饰的基本数据类型字段和String类型的静态字段(以常量表达式初始化的才算)
  3. final修饰的基本数据类型和String类型的局部变量。(也是以常量表达式初始化的才算)
  4. 以常量表达式为操作数的算数和关系运算表达式,以及对于String的+拼接运算符。

对于上述的情况,编译器在遇到相关运算符是,就必须检查其操作数都是常量表达式,如果是的话就必须在编译时对该运算符做常量折叠。

大佬的原文:对于一个很复杂的常量表达式,编译器会算出结果再编译吗?

很明显,对于上面的代码中第4行和第5行都满足于常量折叠的情况,因此编译器在编译期对运算进行了优化,并将运算结果的字符串信息保存在了Class文件的常量池中,所以在运行期间的操作和字符串字面量赋值时相同,最终s3s4的指向也相同。

例3:

如果对final修饰的String局部变量进行初始化时不是常量表达式,则不会发生常量折叠情况。

public static void main(String[] args){
        String s1 = "ab";          
        String s2 = "cd";          
        String s3 = "ab" + "cd";   
        final String fs1 = s1;     // 对fs1的赋值是一个变量
        final String fs2 = s2;     // 对fs2的赋值时一个变量
        String s4 = fs1 + fs2;     // 做运算时,尽管fs1和fs2被final修饰,但它们的值是一个变量所以不满住常量折叠
        System.out.println(s3 == s4);  // false
}

再来看下反汇编代码:

从最终的输出结果和反汇编代码来看,s4 = fs1 + fs2这行代码并没有被编译器优化,所以s3中保存的是“abcd”常量字符串的引用,s4则指向了堆中新创建的字符串对象。

concat()方法拼接字符串

使用concat()方法拼接字符串,该方法只接受String类的参数,也就是其它数据类型需要调用该类对应的toString()方法才能作为参数。我们来看下该方法内部的实现:

从其内部方法的实现可以看出,每一次concat()都需要在堆空间中新创建一个数组,同时也需要在堆空间中创建一个String类对象,即使是两个常量字符串进行拼接,也会在堆中创建一个新的字符串对象,并将两个常量字符串的内容拷贝进去。

public static void main(String[] args){
    String s1 = "ab" + "cd";       // s1 指向常量池中的字符串对象
    String s2 = "ab".concat("cd"); // s2 指向堆中的字符串对象
    System.out.println(s1 == s2);  // false
}

内存图如下:

两种字符串拼接的效率对比

在上面介绍的两种字符串拼接方式中,使用+运算符进行拼接时,实际是创建了StringBuilderString类2个对象,并且还会将StringBuilder底层数组中的内容拷贝到新数组中。而如果使用concat()进行字符串拼接时,实际上只创建了一个对象和一个数组。因此从效率上来说,concat()方法进行字符串拼接时的效率应该要高于+运算符拼接字符串。

对此进行如下测试:

public class StringTest1 {

    public static void main(String[] args) {
        String s1 = "";
        String s2 = "";
        String s3 = "abc";
        long start = System.currentTimeMillis();
        for (int i = 0; i < 20000; i++) {
            s1 += s3;
        }
        long end = System.currentTimeMillis();
        System.out.println("使用运算符拼接: " + (end - start));

        start = System.currentTimeMillis();
        for (int i = 0; i < 20000; i++) {
            s2.concat(s3);
        }
        end = System.currentTimeMillis();
        System.out.println("使用concat拼接: " + (end - start));
    }
}

输出的结果非常明显。concat的效率原高于+运算符拼接字符串。不过这不能说明concat的效率就高于了StringBuilder,主要原因是因为+运算符的底层实现并不适合在循环中进行大量拼接,每次循环都得创建2个对象,如果是对象不是字符串类型还得先将其转换为字符串类型。所以效率相对于concat要低。不过对于大量的字符串拼接,一般也不会使用concat,后面介绍StringBuilder时,会了解到当正确使用了StringBuilder它的效率才是最高的,这也是编译器对于+的底层优化选择使用StringBuilder的原因。

intern()手动入池

intern()方法是Java提供的一个将字符串手动添加进字符串常量池的方法。以下是JDK8文档中的解释:

我英文不好,文中大概的意思是,当该方法被调用时,如果池中已经存在一个等价于当前字符串的对象(通过equals方法决定是否相同),那么返回池中这个字符串。否则将这个字符串对象添加进常量池中,并返回这个这个字符串的引用。

说实话,这个解释说的比较模糊(可能是我没看懂😂),我在网上查了一些资料后总结出比较靠谱的解释是:

当该方法被调用时,字符串常量池中如果已经存在指向内容相同字符串的引用,则返回这个引用,如果没有找到指向相同字符串的引用,则将堆中对象的引用存入字符串常量池中,并返回这个引用。也就是说字符串常量池中其实存的只是字符串的一个引用,并没有存放该对象的实例。

针对于两种情况,分别看一下下面的例子:

例1:当字符串常量池中已经存在引用时

public static void main(String[] args){
    String s1 = "hello";
    String s2 = new String("hel") + new String("lo");
    s2.intern();
    System.out.println(s1 == s2);   // false, s1存放的是"hello"的引用, s2存放的依然是堆中对象的引用,s2.intern()会返回, 常量池中"hello"的引用, 但不会改变s2原本的值。
}

还是看反汇编把,比我干打字要直观一点🤣

内存图大概如下(画的有点乱…):

例2:当字符串常量池不存在引用时。

public static void main(String[] args){
    String s2 = new String("hel") + new String("lo");
    s2.intern();  // s2所在对象的引用入常量池
    String s1 = "hello"; // 保存了常量池中的引用,与s2指向同一个对象
    System.out.println(s1 == s2);   // true
}

在之前画的图里面都将指向字符串常量对象的引用变量直接指向了字符串常量池中,其实不太准确,因为字符串常量池中保存的都是字符串常量对象的引用,而这个对象其实是在堆空间中存放的。不过一开始我也没清楚了解到这些内容,并且网上很多图也是这样画的,我想应该是这样作图可能更加直观把。

字符串的不可变性

String类被声明为final,代表了这个类无法被继承,而存放字符串的底层数组引用value也被声明为了private final char[],所以我们通过正常的手段是也无法从外部访问到这个value这个属性,也不能改变value的指向。

不可变性的体现

  1. 当对String类引用变量重新赋值时,改变的是该变量所指向的字符串对象,而不是原对象中value数组中的内容。
  2. 当对现有的字符串进行拼接字符串操作时,会产生新的字符串对象,原字符串不会改变。
  3. 当调用String的replace()方法时,替换字符时,也时重新开辟了新的内存空间,并使得引用指向了这块空间。

虽然无法通过正常手段访问value,但是也可以通过一些特殊手段访问到这个私有属性,只要能访问到该属性拿到它所指向的数组引用,就可以修改其中的内容,方法就是使用反射机制来获取运行时类的属性。

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {

        String s1 = "abc";
        System.out.println(s1);  // abc
        char[] chars = {'1', '2', '3'};
        Field value = String.class.getDeclaredField("value");  // 获取运行时类的value属性
        value.setAccessible(true); // 设置可以访问私有属性
        value.set(s1, chars);  // 将s1中value指向的数组改为chars
        System.out.println(s1);  //123, value的指向被改变

        char[] chars1 = (char[]) value.get(s1);  // 获取value指向的这个数组的引用
        chars1[0] = '2';  // 改变这个数组的内容

        System.out.println(s1);   //  223, value所指向数组的内容也发生改变

    }

上面的内容也只是一个扩展了解,既然Java的开发工程师们如此小心的将value封装起来,目的不就是不想让我们去其中的值嘛,所以如果没有什么特殊需求,不建议使用这种方式去破坏字符串的不可变性。并且反射的主要作用也不是拿来做这个的。

String的常用方法

字符串比较

方法名功能
boolean equals(Object anObject)比较字符串的内容是否相同。
boolean equalsIgnoreCase(String anotherString)与equals方法类似, 忽略大小写。
int compareTo(String another)比较两个字符串的大小。
@Test
public void test1(){
	String s1 = "HelloWorld";

//  1. equals() 比较字符串内容是否相同
    System.out.println("HelloWorld".equals(s1)); // true
    System.out.println("helloworld".equals(s1)); // false,比较大小写

//  2. equalsIgnoreCase()  与equals方法类似, 忽略大小写
    System.out.println("helloworld".equalsIgnoreCase(s1)); // true
    System.out.println("HELLOWORLD".equalsIgnoreCase(s1)); // true

//  3. compareTo 比较两个字符串的大小
/*
	比较规则是基于每个字符的Unicode编码:
	如果当前字符串 > 参数字符串,返回大于0的数字
	如果当前字符串 < 参数字符串,返回小于0的数字
	如果两个字符串完全相同,返回0
*/     

	System.out.println("ABC".compareTo("ABE")); // -2
	System.out.println("aBC".compareTo("ABE")); // 32
	System.out.println("ABC".compareTo("ABC")); //  0
}

String重写后的compareTo方法

字符串查找

方法名功能
boolean contains(CharSequence s)判断当前字符串中是否包含指定的char值序列。
char charAt(int index)返回index索引处的字符,return value[index]
int indexOf(String str)返回参数字符串在此字符串中第一次出现处的索引。
int indexOf(String str, int fromIndex)返回参数字符串在此字符串中第一次出现处的索引,从指定的索引开始。
int lastIndexOf(String str)返回参数字符串在此字符串中最右边出现处的索引,从右至左查找。
int lastIndexOf(String str, int fromIndex)返回参数字符串在此字符串中最后一次出现处的索引,从指定的索引开始从右至左查找。

注: indexOflastIndexOf方法如果未找到返回值为-1 。

@Test
public void test2(){
    // 1. contains(CharSequence s) 判断一个字符串中是否包含指定char值序列, 严格要求大小写
    String s1 = "HelloWorld";
    System.out.println(s1.contains("llo")); (转)如何学习Java技术?谈Java学习之路

2022年Java学习笔记目录

想学好java,需要学习些啥以及学习步骤是啥

学习java周期与学习方式的关系

Java 学习路线

如何学习Java?学习Java顺序?