Java学习 -- StringStringBufferStringBuilder
Posted 庸人冲
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java学习 -- StringStringBufferStringBuilder相关的知识,希望对你有一定的参考价值。
文章目录
String
String概述
java.lang.String类代表字符串。
- Java 程序中的所有字符串字面值(如 “abc” )都作为此类的实例实现。
- String类声明为final, 不可被继承。
- String内部定义了**
private final char[] value
**用于存储字符串数据(JDK8
)。 - String类实现了Serializable接口:表示字符串是支持序列化的。
- String类实现了Comparable接口:表示String可以比较大小。
String类的声明以及属性如下:
String实例化
String类的实例化可以分为字面量赋值和**new + 构造器()**两种分式。
字面量赋值
由“”
引起的字面量都做为String类
的实例,可以直接赋值给String
类的引用变量。
String str = "hello";
在Java中由
“”
引起的字面量在编译期会以CONSTANT_String
和CONSTANT_Utf8
的形式被保存**Class
文件的常量池中,在类加载阶段这些字面量会进入当前类的运行时常量池**,当字面量第一被使用时(ldc指令推入栈顶),才会以字符串对象的形式在堆中创建对象,并在字符串常量池(StringTable)保存该对象的引用。
关于字面量进入字符串常量池的具体细节请参阅此处: Java 中new String(“字面量”) 中 “字面量” 是何时进入字符串常量池的?
上面的str
中实际保存的是字符串常量池中的“hello”的引用。对此我们可以使用javap -verbose
指令进行查看。
构造器实例化
String类重载的构造器有很多,大概有十几种重载的构造器,Java的官方文档中可以查询到,这里不再列出。需要注意的是文档中标注Deprecated
的构造器表示已经弃用,不建议使用。
对于这么多的构造器我们不需要全部了解,只需要掌握常用一些构造器即可,其它的构造器用到时,再去查阅文档。
常用构造器
String(String original)
该构造器中的参数为一个字符串字面量。
String str = new String("hello");
这种实例化方式与字面量直接赋值的区别在于:
-
字面量赋值时
String
类引用变量会直接指向字符串常量池中的字符串。 -
**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指向的不是不一块内存空间。原因如下:
-
如果是字面量的方式进行拼接字符串,那么在编译期间就会进行对字面量进行运算并将新字符串的信息Class文件的常量池中,当运行时
“abcd”
以字符串形式放入字符串常量池中,s3接收的到的就是常量池中“abcd”
字符串的引用。 -
而如果是涉及到变量的拼接字符串,那么在运行期间
+
运算符实际上在底层创建了一个StringBuilder
类的对象(后面会介绍StringBuilder类),并调用该对象的append()
方法将字符串添加进对象中,然后再转换为一个String
类的对象并返回该对象的引用。所以s4其实是指向了堆空间中新建的String
类对象。
内存图如下:
这一块我进源码看了一下,里面太复杂了差点没绕出来。StringBuilderd
的append()
和toString()
方法最终都调用了native
的System.arraycopy()
方法进行数组拷贝,这块应该属于JVM的内容了属实超纲了,如果有不对的地方还请大佬们指正。
例2:
如果使用final
来修饰s1
和s2
,那么结果又不一样:
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
中符合常量折叠的情况:
- 八大基本数据类型和String类的字面量。
static final
修饰的基本数据类型字段和String类型的静态字段(以常量表达式初始化的才算)final
修饰的基本数据类型和String类型的局部变量。(也是以常量表达式初始化的才算)- 以常量表达式为操作数的算数和关系运算表达式,以及对于String的
+
拼接运算符。
对于上述的情况,编译器在遇到相关运算符是,就必须检查其操作数都是常量表达式,如果是的话就必须在编译时对该运算符做常量折叠。
大佬的原文:对于一个很复杂的常量表达式,编译器会算出结果再编译吗?
很明显,对于上面的代码中第4行和第5行都满足于常量折叠的情况,因此编译器在编译期对运算进行了优化,并将运算结果的字符串信息保存在了Class
文件的常量池中,所以在运行期间的操作和字符串字面量赋值时相同,最终s3
和s4
的指向也相同。
例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
内存图如下:
两种字符串拼接的效率对比
在上面介绍的两种字符串拼接方式中,使用+
运算符进行拼接时,实际是创建了StringBuilder
和String
类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
的指向。
不可变性的体现:
- 当对String类引用变量重新赋值时,改变的是该变量所指向的字符串对象,而不是原对象中value数组中的内容。
- 当对现有的字符串进行拼接字符串操作时,会产生新的字符串对象,原字符串不会改变。
- 当调用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) | 返回参数字符串在此字符串中最后一次出现处的索引,从指定的索引开始从右至左查找。 |
注: indexOf
和lastIndexOf
方法如果未找到返回值为-1 。
@Test
public void test2()
// 1. contains(CharSequence s) 判断一个字符串中是否包含指定char值序列, 严格要求大小写
String s1 = "HelloWorld";
System.out.println(s1.contains("llo")); // true
System.out.println(转)如何学习Java技术?谈Java学习之路