跳到主要内容

String

String 是什么呢?

String 类是 Java 中最常用的类之一,它表示字符串。字符串是字符序列,字符串对象是不可变的,也就是说,一旦创建了字符串对象,就不能再改变它的内容。

String 为什么是不可变的?

🧱 首先,记住 String 的核心特性:不可变性 (Immutable) String 对象一旦被创建,它的值就​​不能被改变​​。所以,每一个看起来修改字符串的操作(比如截取、替换),​​实际上都是创建并返回了一个全新的 String 对象​​,原来的字符串纹丝不动。

这就像是在石碑上刻字 🪦,刻好了就不能修改。如果你想得到不同的内容,只能​​重新刻一块新石碑​​。

​​好处​​:安全、高效(例如可用于字符串常量池)。

可变性

🙂简单的来说:String 类中使用 final 关键字修饰字符数组来保存字符串,所以String 对象是不可变的。

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
private final char value[];
//...
}

🐛 修正 :

我们知道被 final 关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。

因此,final 关键字修饰的数组保存字符串并不是 String 不可变的根本原因,因为这个数组保存的字符串是可变的(final 修饰引用类型变量的情况)。

String 真正不可变有下面几点原因:

  1. 保存字符串的数组被 final 修饰且为私有的,并且String 类没有提供/暴露修改这个字符串的方法。

  2. String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变。

String对象一旦被创建就是固定不变的了,对String对象的任何改变都不影响到原对象,相关的任何change操作都会生成新的对象。

String、StringBuffer、StringBuilder 的区别?

在 Java 9 之后,StringStringBuilderStringBuffer 的实现改用 byte 数组存储字符串。

StringBuilderStringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,不过没有使用 finalprivate 关键字修饰,最关键的是这个 AbstractStringBuilder 类还提供了很多修改字符串的方法比如 append 方法。

线程安全性

String 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilderStringBuilderStringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacityappendinsertindexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

字符串拼接用“+” 还是 StringBuilder?

Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的元素符。

性能

每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。

相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。

对于三者使用的总结:

  1. 操作少量的数据: 适用 String

  2. 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder

  3. 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer

💡 重要提示

  • 字符串拼接性能:在循环中拼接字符串,不要直接用 +concat(),而要用 StringBuilderStringBuffer(线程安全),否则会创建大量中间对象,性能极差!🚨

    // ❌ 性能差
    String result = "";
    for (int i = 0; i < 1000; i++) {
    result += i; // 每次循环都创建一个新String对象!
    }

    // ✅ 性能好
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < 1000; i++) {
    sb.append(i);
    }
    String result = sb.toString();

字符串常量池(重要)

我们知道字符串的分配和其他对象分配一样,是需要消耗高昂的时间和空间的,而且字符串我们使用的非常多。

JVM为了提高性能和减少内存的开销,在实例化字符串的时候进行了一些优化:使用字符串常量池每当我们创建字符串常量时,JVM会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就直接返回常量池中的实例引用。如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中。由于String字符串的不可变性我们可以十分肯定常量池中一定不存在两个相同的字符串(这点对理解上面至关重要)。

Java中的常量池,实际上分为两种形态:静态常量池运行时常量池。所谓静态常量池,即*.class文件中的常量池,class文件中的常量池不仅仅包含字符串(数字)字面量,还包含类、方法的信息,占用class文件绝大部分空间。而运行时常量池,则是jvm虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池

String a="hello";
String b="hello";

很明显,a、b和字面上的chenssy都是指向JVM字符串常量池中的"chenssy"对象,他们指向同一个对象。而且是在编译器就确定了。所以a==b 为true。

String c=new String("hello");

new关键字一定会产生一个对象chenssy(注意这个chenssy和上面的chenssy不同),同时这个对象是存储在堆中。所以上面应该产生了两个对象:保存在栈中的c和保存堆中chenssy。

但是在Java中根本就不存在两个完全一模一样的字符串对象。故堆中的chenssy应该是引用字符串常量池中chenssy。所以c、chenssy、池chenssy的关系应该是:c--->chenssy--->池chenssy。

总结 虽然a、b、c、chenssy是不同的对象,但是从String的内部结构我们是可以理解上面的。

String c = new String("chenssy");虽然c的内容是创建在堆中,但是他的内部value还是指向JVM常量池的chenssy的value,它构造chenssy时所用的参数依然是chenssy字符串常量。

//1.编译器确定
public void test1(){
String str1="aaa";
String str2="aaa";
System.out.println("===========test1============");
System.out.println(str1==str2);//true 可以看出str1跟str2是指向同一个对象
}

我们来看一看内部操作的过程:

当执行String str1="aaa"时,JVM首先会去字符串池中查找是否存在"aaa"这个对象,不存在,则在字符串池中创建"aaa"这个对象,然后将池中"aaa"这个对象的引用地址返回给字符串常量str1,这样str1会指向池中"aaa"这个字符串对象;

当创建字符串对象str2时,字符串池中已经存在"aaa"这个对象,直接把对象"aaa"的引用地址返回给str2,这样str2指向了池中"aaa"这个对象,也就是说str1和str2指向了同一个对象,因此语句System.out.println(str1 == str2)输出:true。

//编译器不确定
public void test2(){
String str3=new String("aaa");
String str4=new String("aaa");
System.out.println("===========test2============");
System.out.println(str3==str4);//false 可以看出用new的方式是生成不同的对象
}

采用new关键字新建一个字符串对象时,JVM首先在字符串池中查找有没有"aaa"这个字符串对象,如果有,则不在池中再去创建"aaa"这个对象了,直接在堆中创建一个"aaa"字符串对象,然后将堆中的这个"aaa"对象的地址返回赋给引用str3,这样,str3就指向了堆中创建的这个"aaa"字符串对象;

如果没有,则首先在字符串池中创建一个"aaa"字符串对象,然后再在堆中创建一个"aaa"字符串对象,然后将堆中这个"aaa"字符串对象的地址返回赋给str3引用,这样,str3指向了堆中创建的这个"aaa"字符串对象。

当执行String str4=new String("aaa")时, 因为采用new关键字创建对象时,每次new出来的都是一个新的对象,也即是说引用str3和str4指向的是两个不同的对象,因此语句System.out.println(str3 == str4)输出:false。

/**
* 编译期确定
*/
public void test3(){
String s0="helloworld";
String s1="helloworld";
String s2="hello"+"world";
System.out.println("===========test3============");
System.out.println(s0==s1); //true 可以看出s0跟s1是指向同一个对象
System.out.println(s0==s2); //true 可以看出s0跟s2是指向同一个对象
}

因为例子中的s0和s1中的"helloworld”都是字符串常量,它们在编译期就被确定了,所以s0==s1为true;而"hello”和"world”也都是字符串常量,当一个字符串由多个字符串常量连接而成时,它自己肯定也是字符串常量,所以s2也同样在编译期就被解析为一个字符串常量,所以s2也是常量池中"helloworld”的一个引用。所以我们得出s0==s1==s2。

/**
* 编译期无法确定
*/
public void test4(){
String s0="helloworld";
String s1=new String("helloworld");
String s2="hello" + new String("world");
System.out.println("===========test4============");
System.out.println( s0==s1 ); //false
System.out.println( s0==s2 ); //false
System.out.println( s1==s2 ); //false
}

分析:用new String() 创建的字符串不是常量,不能在编译期就确定,所以new String() 创建的字符串不放入常量池中,它们有自己的地址空间。

s0还是常量池中"helloworld”的引用,s1因为无法在编译期确定,所以是运行时创建的新对象"helloworld”的引用,s2因为有后半部分new String(”world”)所以也无法在编译期确定,所以也是一个新创建对象"helloworld”的引用。

/**
* 继续-编译期无法确定
*/
public void test5(){
String str1="abc";
String str2="def";
String str3=str1+str2;
System.out.println("===========test5============");
System.out.println(str3=="abcdef"); //false
}

分析:因为str3指向堆中的"abcdef"对象,而"abcdef"是字符串池中的对象,所以结果为false。JVM对String str="abc"对象放在常量池中是在编译时做的,而String str3=str1+str2是在运行时刻才能知道的。new对象也是在运行时才做的。而这段代码总共创建了5个对象,字符串池中两个、堆中三个。+运算符会在堆中建立来两个String对象,这两个对象的值分别是"abc"和"def",也就是说从字符串池中复制这两个值,然后在堆中创建两个对象,然后再建立对象str3,然后将"abcdef"的堆地址赋给str3。

步骤:

1)栈中开辟一块中间存放引用str1,str1指向池中String常量"abc"。

2)栈中开辟一块中间存放引用str2,str2指向池中String常量"def"。

3)栈中开辟一块中间存放引用str3。

4)str1 + str2通过StringBuilder的最后一步toString()方法还原一个新的String对象"abcdef",因此堆中开辟一块空间存放此对象。

5)引用str3指向堆中(str1 + str2)所还原的新String对象。

6)str3指向的对象在堆中,而常量"abcdef"在池中,输出为false。

public void test6(){
String s0 = "a1";
String s1 = "a" + 1;
System.out.println("===========test6============");
System.out.println((s0 == s1)); //result = true
String s2 = "atrue";
String s3= "a" + "true";
System.out.println((s2 == s3)); //result = true
String s4 = "a3.4";
String s5 = "a" + 3.4;
System.out.println((s4 == s5)); //result = true
}

分析:在程序编译期,JVM就将常量字符串的"+"连接优化为连接后的值,拿"a" + 1来说,经编译器优化后在class中就已经是a1。在编译期其字符串常量的值就确定下来,故上面程序最终的结果都为true。

/**
* 编译期无法确定
*/
public void test7(){
String s0 = "ab";
String s1 = "b";
String s2 = "a" + s1;
System.out.println("===========test7============");
System.out.println((s0 == s2)); //result = false
}

分析:JVM对于字符串引用,由于在字符串的"+"连接中,有字符串引用存在,而引用的值在程序编译期是无法确定的,即"a" + s1无法被编译器优化,只有在程序运行期来动态分配并将连接后的新地址赋给s2。

所以上面程序的结果也就为false。

/**
* 编译期确定
*/
public void test9(){
String s0 = "ab";
final String s1 = "b";
String s2 = "a" + s1;
System.out.println("===========test9============");
System.out.println((s0 == s2)); //result = true
}

执行上述代码,结果为:true。

分析:和例子7中唯一不同的是s1字符串加了final修饰,对于final修饰的变量,它在编译时被解析为常量值的一个本地拷贝存储到自己的常量池中或嵌入到它的字节码流中。所以此时的"a" + s1和"a" + "b"效果是一样的。

故上面程序的结果为true。

/**
* 编译期无法确定
*/
public void test10(){
String s0 = "ab";
final String s1 = getS1();
String s2 = "a" + s1;
System.out.println("===========test10============");
System.out.println((s0 == s2)); //result = false

}

private static String getS1() {
return "b";
}

分析:这里面虽然将s1用final修饰了,但是由于其赋值是通过方法调用返回的,那么它的值只能在运行期间确定,因此s0和s2指向的不是同一个对象,故上面程序的结果为false。

intern方法

public static void main(String args[]){
String s1=new String("hello1");
System.out.println(s1.intern()==s1);//false
System.out.println(s1.intern()=="hello1");//true

String s2="hello2";
System.out.println(s2.intern()==s2);//true

}

1、执行intern方法时,如果常量池中存在和String对象相同的字符串,则返回常量池中对应字符串的引用;

2、如果常量池中不存在对应的字符串,则添加该字符串到常量中,并返回字符串引用;

🧰 String 类方法大全(超实用清单)

我把常用方法分成了几大类,方便你查阅和理解。

1. 获取信息 ℹ️ (查询)

这些方法就像是在检查字符串的“体检报告”。

方法作用例子 & 输出
length()获取字符串的长度"Hello".length() -> 5
charAt(int index)获取指定索引位置的字符"Hello".charAt(1) -> 'e'
indexOf(String str)返回子串第一次出现的索引"Hello".indexOf("l") -> 2
lastIndexOf(String str)返回子串最后一次出现的索引"Hello".lastIndexOf("l") -> 3
isEmpty()判断字符串是否为length() == 0"".isEmpty() -> true
isBlank()判断字符串是否为空白(空格、制表符等)" ".isBlank() -> true

2. 比较与判断 ⚖️

这些方法用来比较字符串的内容。

方法作用例子 & 输出
equals(Object obj)比较内容是否相等(强烈推荐用这个!"Hi".equals("Hi") -> true
equalsIgnoreCase(String str)忽略大小写,比较内容是否相等"Hi".equalsIgnoreCase("hI") -> true
contains(CharSequence s)判断是否包含指定的子串"Hello".contains("ell") -> true
startsWith(String prefix)判断是否以指定前缀开头"Hello".startsWith("He") -> true
endsWith(String suffix)判断是否以指定后缀结尾"Hello".endsWith("lo") -> true

3. 操作与变换 🛠️ (生成新字符串)

这些方法会“创造”出新的字符串。

方法作用例子 & 输出
substring(int beginIndex)截取子串(从开始索引到结尾)"Hello".substring(2) -> "llo"
substring(int begin, int end)截取子串([开始索引, 结束索引) 左闭右开"Hello".substring(1, 4) -> "ell"
concat(String str)拼接字符串"Hello".concat(" World!") -> "Hello World!"
replace(char old, char new)替换所有出现的指定字符"Hello".replace('l', 'p') -> "Heppo"
replace(CharSequence old, CharSequence new)替换所有出现的指定序列"Hi there!".replace("Hi", "Bye") -> "Bye there!"
toLowerCase()转换为小写"Hello".toLowerCase() -> "hello"
toUpperCase()转换为大写"Hello".toUpperCase() -> "HELLO"
trim()去除字符串首尾的空白字符" Hi ".trim() -> "Hi"
strip()去除字符串首尾的空白字符(功能比trim更强,支持Unicode)" Hi ".strip() -> "Hi"

4. 转换与拆分 🧩

这些方法是处理字符串的“瑞士军刀”。

方法作用例子 & 输出
toCharArray()将字符串转换为字符数组 char[]"Hi".toCharArray() -> ['H', 'i']
split(String regex)根据正则表达式分割字符串(超级常用!)见下方详解 👇
join(CharSequence delimiter, CharSequence... elements)用指定的分隔符连接多个字符串String.join("-", "A", "B", "C") -> "A-B-C"

🎯 实际操作演示

1. 如何分割字符串?split() 🔪

这是处理文本数据(如CSV文件)的利器。

String data = "Apple,Banana,Orange,Watermelon";
// 使用逗号分割字符串,得到一个字符串数组
String[] fruits = data.split(",");

for (String fruit : fruits) {
System.out.println(fruit);
}
// 输出:
// Apple
// Banana
// Orange
// Watermelon

// 高级用法:使用正则表达式
String sentence = "Hello World! How are you?";
// 按空格、感叹号、问号分割
String[] words = sentence.split("\\s+|!|\\?");
for (String word : words) {
System.out.println(word);
}
// 输出:
// Hello
// World
// How
// are
// you

2. 如何匹配与查找?matches() & contains() 🔍

// 1. 判断是否完全匹配一个正则表达式(规则)
String email = "user@example.com";
boolean isValidEmail = email.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$");
System.out.println("Is valid email? " + isValidEmail); // 输出: true or false

// 2. 判断是否包含简单子串
String message = "Welcome to Java programming!";
boolean hasJava = message.contains("Java");
System.out.println("Contains 'Java'? " + hasJava); // 输出: true

3. 综合小例子:处理用户输入 🎮

String userInput = "  , apple, BANANA; Orange  ";

// 处理流程:1.去首尾空格 2.统一分隔符 3.按新分隔符分割 4.转换为小写并去除元素首尾空格
String[] cleanedFruits = userInput
.trim() // "apple, BANANA; Orange"
.replace(';', ',') // 将分号统一替换为逗号 "apple, BANANA, Orange"
.split("\\s*,\\s*"); // 按“逗号+任意空格”分割

for (String fruit : cleanedFruits) {
System.out.println(fruit.toLowerCase().strip()); // 转换为小写并去除可能残留的空格
}
// 输出:
// apple
// banana
// orange