JDK9 后对 String 底层代码做了优化,不再使用 char 数组,改用 byte 数组。JDK9的String其实支持两个编码方案:Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间。如果字符串中包含的汉字超过 Latin-1 可表示范围内的字符,byte 和 char 所占用的空间是一样的。 (若字符全是ASCII字符,使用value使用LATIN编码,若存在一个非ASCII字符,则用UTF16编码。这种方式使得英文场景占更少的内存。缺点是JDK9的StringAPI性能可能不如JDK8,特别是传入char[]构造字符串,会被压缩为latin编码的byte[],有些场景性能下降10%。)
String 的长度是有限制的:
Java中字符串在常量池中通过CONSTANT_Utf8类型表示。
CONSTANT_Utf8_info { u1 tag; u2 length; u1 bytes[length];}
bytes[length]:length在这里就是代表字符串的长度,length的类型是u2,u2是无符号的16位整数,也就是说最大长度可以做到2^16-1 即 65535。因为javac编译器做了限制,需要length < 65535。所以字符串常量在常量池中的最大长度是65534。
总结:字符串有长度限制,在编译期,要求字符串常量池中的常量不能超过65535,并且在javac执行过程中控制了最大值为65534。在运行期,长度不能超过Int的范围,否则会抛异常。
字符串是 Java 中特殊的类,有两种定义方式:直接定义字符串和使用String类定义。
直接定义字符串是指使用双引号包围一串字符。具体方法是用字符串常量直接初始化一个 String类的对象,如字符串“Hello”在编译后即成为 String 对象。
String str = "Hello Java";或者String str;str = "Hello Java";
注意:字符串变量必须经过初始化才能使用。
使用String类定义是使用 String 类的构造方法来创建字符串。String 类的构造方法有多种重载形式,每种形式都可以定义字符串。因此有多种形式定义字符串。
1. String()
初始化一个新创建的 String 对象,表示一个空字符序列。
2. String(String original)
初始化一个新创建的 String 对象,使其表示一个与参数相同的字符序列。换句话说,新创建的字符串是该参数字符串的副本
String str1 = new String("Hello Java");String str2 = new String(str1);// 这里 str1 和 str2 的值是相等的。System.out.println("值是否相同=="+ s1.equals(s2)); // trueSystem.out.println("是否同一个对象=="+ (s1 == s2)); // false
3. String(char[] value)
分配一个新的字符串,将参数中的字符数组元素全部变为字符串。该字符数组的内容已被复制,后续对字符数组的修改不会影响新创建的字符串。
char a[] = {'H','e','l','l','0'};String sChar = new String(a);a[1] = 's';// sChar变量的值是字符串“Hello”。 即使在创建字符串之后,// 对a数组中的第2个元素进行了修改,但未影响sChar的值。
4. String(char[] value,int offset,int count)
分配一个新的 String,它包含来自该字符数组参数一个子数组的字符。offset 参数是子数组第一个字符的索引,count 参数指定子数组的长度。该子数组的内容已被赋值,后续对字符数组的修改不会影响新创建的字符串。
char a[]={'H','e','l','l','o'};String sChar=new String(a,1,4);a[1]='s';
String转int(2种方式)
注意:转换时,String 的值一定是整数,否则会报数字转换异常(
java.lang.NumberFormatException)。
int转String(3种方式)
注意:使用第三种方法相对第一第二种耗时比较大。
toString()、valueOf()和(String)强转区别:
// 确定类型null,返回字符串"null"String teString=null; System.out.println(String.valueOf(teString));// 直接传null,报错System.out.println(String.valueOf(null);// 原因:valueOf有多种重载,而java中对重载的匹配是当重载都能匹配的时候,// 优先选择范围小,精度高的方法,因此后者自动重载了char[]这个方法。
1、String 字符串虽然是不可变字符串,但可以通过拼接产生一个新的对象。String 字符串拼接可以使用“+”运算符或 String 的 concat(String str) 方法。“+”运算符优势是可以连接任何类型数据拼接成为字符串,而 concat 方法只能拼接 String 类型字符串.
注意:只要“+”运算符的一个操作数是字符串,编译器就会将另一个操作数转换成字符串形式,所以应该谨慎地将其他数据类型与字符串相连,以免出现意想不到的结果。
Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的元素符。
String str1 = "he";String str2 = "llo";String str3 = "world";String str4 = str1 + str2 + str3;
可以看出,字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。
在循环内使用“+”进行字符串的拼接的话,编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象。
StringJoiner()是java8新增的工具类:用于字符串拼接,可替代StringBuilder或StringBuffer。
2、toLowerCase()和toUpperCase() 方法可以将字符串中的所有字符全部转换成小写和大写,而非字母的字符不受影响。
3、trim()方法去除首尾空格, 只能去掉字符串中前后的半角空格(英文空格),而无法去掉全角空格(中文空格)。可用:str.replace((char) 12288, ' '); 将全角空格替换为半角空格再进行操作。
4、substring() 方法是按字符截取,而不是按字节截取。
JDK6 和JDK7 中substring(int beginIndex, int endIndex)方法的区别:
JDK6中String 可被标示成字符数组。在JDK6中,String 类包含三个属性:char value[], int offset,int count。它们被分别用于存储实际的字符串数组,数组第一个元素的标示,字符串中字符的个数。 当substring()方法被调用时候,它创建了一个新的字符串,但是这个字符串的值仍然指向在堆中相同的数组。原来字符串和新字符串的区别是他们的count和offset的值不同。(substring产生的string对象和原来string对象共用一个char[] value,这会导致substring方法返回的string的char[]被引用而无法被GC回收) 如图:
源码:
//JDK 6String(int offset, int count, char value[]) { this.value = value; this.offset = offset; this.count = count;}public String substring(int beginIndex, int endIndex) { //check boundary return new String(offset + beginIndex, endIndex - beginIndex, value);}
注意:JDK6中substring引起的问题:如果你有个非常长的字符串,但是你每次想通过用substring()来取其中一小部分。这将会产生一个性能问题,你仅仅需要一小部分,但是你保留了整个。在JDK6中,通过下面的方法,将使它指向一个真正的子串。 推荐可以用如下方法: x = new String(x.substring(x, y)); 或 x = x.substring(x, y) + ""。因此在JDK7中对substring方法做了改进,在堆上创建了一个新的数组。如图:
源码简化:
//JDK 7public String(char value[], int offset, int count) { //check boundary this.value = Arrays.copyOfRange(value, offset, offset + count);} public String substring(int beginIndex, int endIndex) { //check boundary int subLen = endIndex - beginIndex; return new String(value, beginIndex, subLen);}
注意:因为有拷贝另外一个,JDK7比JDK6的substring方法效率低些,因内存占用过大影响效率问题。
5、慎用 split() 方法,Split 方法大多数情况下使用的是正则表达式, 这种分割方式本身没有什么问题,但由于正则表达式的性能是非常不稳定的,使用不恰当会引起回溯问题,很可能导致 CPU 居高不下。
String badRegex = "^([hH][tT]提高效率[pP]://|[hH][tT]提高效率[pP][sS]://)(([A-Za-z0-9-~]+).)+([A-Za-z0-9-~\/])+$";String bugUrl = "http://www.apigo.com/dddp-web/pdf/download?request=6e7JGxxxxx4ILd-kExxxxxxxqJ4-CHLmqVnenXC692m74H38sdfdsazxcUmfcOH2fAfY1Vw__%5EDadIfJgiEf";if (bugUrl.matches(badRegex)) { System.out.println("match!!");} else { System.out.println("no match!!");}
Java 正则表达式使用的引擎实现是 NFA(Non deterministic Finite Automaton,不确定型有穷自动机)自动机,这种正则表达式引擎在进行字符匹配时会发生回溯(backtracking),而一旦发生回溯,那其消耗的时间就会变得很长,有可能是几分钟,也有可能是几个小时,时间长短取决于回溯的次数和复杂度。
所以应该慎重使用 Split() 方法,可以用 indexOf() 方法代替 split() 方法完成字符串的分割。如果实在无法满足需求,在使用 Split() 方法时,对回溯问题加以重视就可以了。
6、replace() 方法用于将目标字符串中的指定字符(串)替换成新的字符(串)。格式:replace(String oldChar, String newChar);
replaceFirst() 方法用于将目标字符串中匹配某正则表达式的第一个子字符串替换成新的字符串。格式:replaceFirst(String regex, String replacement);
replaceAll() 方法用于将目标字符串中匹配某正则表达式的所有子字符串替换成新的字符串。格式:replaceAll(String regex, String replacement)
7、善用 String.intern() 方法可以有效的节约内存并提升字符串的运行效率。intern() 方法的定义与源码:
// intern() 是一个高效的本地方法。其作用是把字符串对象的引用保存在字符串常量池中。// 当调用 intern 方法时,// 如果字符串常量池中已经包含此字符串,则直接返回此字符串的引用,// 如果不包含此字符串,先将字符串添加到常量池中,再返回此对象的引用。public native String intern();// intern方法在JDK不同版本下的差异public class Clazz { public static void main(String[] args) { // jdk6 及以下false false;jdk7 及以上false true String s = new String("1"); s.intern(); String s2 = "1"; System.out.println("s和s2==:"+(s == s2)); String s3 = new String("1") + new String("1"); s3.intern(); String s4 = "11"; System.out.println("s3和s4==:"+(s3 == s4)); // jdk6 及以下false false;jdk7 及以上true true String s5 = new String("b"); String sintern = s5.intern(); String s6 = "b"; System.out.println("sintern和s6==:"+(sintern == s6)); String s7 = new String("b") + new String("b"); String s7intern = s7.intern(); String s8 = "bb"; System.out.println("s7intern和s8==:"+(s7intern == s8)); // jdk6 及以下false false false;jdk7 及以上true false true String s9 = new StringBuilder().append("ja").append("va1").toString(); String s10 = s9.intern(); System.out.println(s9==s10); String s11 = "dmz"; String s12 = new StringBuilder().append("d").append("mz").toString(); String s13 = s12.intern(); System.out.println(s12 == s13); String s14 = new StringBuilder().append("s").append("pring").toString(); String s15 = s14.intern(); String s16 = "spring"; System.out.println(s14 == s15); }}
8、length()
在Java中,字符的数据类型是char,而char类型的编码是 Unicode 编码,因此每一个char类型数据2字节16位,对应在内存中的数据就是字符的 Unicode 的码值。而JDK9前String类型的底层是一个char数组,因此String类型在内存中的存储形式是一系列字符对应的 Unicode 码值。对于String类型的字符串在内存和 .class 文件中的编码都是 Unicode 编码,与源文件无关。
String.length():返回字符串的字符个数(Unicode code units的长度),大多数常用中文算一个字符;
String.getBytes().length:返回字符串的字节长度,一个中文占用字节数和平台的编码有关;
public static void main(String[] args) { // 常见中文字 String s = "你好"; System.out.println("常见中文字,slength="+s.length()); System.out.println("常见中文字,sbyteslength="+s.getBytes().length); System.out.println("常见中文字,scharlength="+s.toCharArray().length); // emojis--表情 s = ""; System.out.println("emojis,slength="+s.length()); System.out.println("emojis,sbyteslength="+s.getBytes().length); System.out.println("emojis,scharlength="+s.toCharArray().length); // 生僻中文字 s = "妹"; System.out.println("生僻中文字,slength="+s.length()); System.out.println("生僻中文字,sbyteslength="+s.getBytes().length); System.out.println("生僻中文字,scharlength="+s.toCharArray().length);}
设置的字符串都是两个unicode字符,输出结果:
常见的字符串编码:LATIN1、UTF-8、UTF-16、GB18030。
现在的Charactor代表的已经不再是一个字符 (代码点 code point), 而是一个代码单元(code unit)。
String的getBytes()方法,会将字符从 Unicode 编码转成参数指定名称的字符集编码的码值(如果没有指明字符集编码,会按照 Jvm 的字符集编码,如果没有指定 Jvm 的字符集编码,会根据操作系统的字符集编码)然后返回这些码值对应的字节数组,由于 UTF-8 是3个字节表示一个汉字,所以返回的字节数组长度是15,而 GBK 编码是2个字节表示一个汉字,所以返回的字节数组长度是10。
Java中有内码和外码之分,内码是char或String在内存里使用的编码方式。外码除了内码都可以认为是“外码”(包括class文件的编码)。unicode(utf-16)中使用的是utf-16。String.length()返回字符串的长度,不再是Unicode中字符的长度,是Code Unit的长度,这一长度等于字符串中的UTF-16的代码单元的数目。代码单元指一种转换格式(UTF)中最小的一个分隔,称为一个代码单元(Code Unit),因此,一种转换格式只会包含整数个单元。UTF-X 中的数字 X 就是各自代码单元的位数。
UTF-16 的 16 指的就是最小为 16 位一个单元,也即两字节为一个单元,UTF-16 可以包含一个单元和两个单元,对应即是两个字节和四个字节。我们操作 UTF-16 时就是以它的一个单元为基本单位的。UTF-16编码一个字符对于U+0000-U+FFFF范围内的字符采用2字节进行编码,而对于字符的码点大于U+FFFF的字符采用四字节进行编码,前者是两字节也就是一个代码单元,后者一个字符是四字节也就是两个代码单元!
UTF-32 以 32 位一个单元,它只包含这一种单元就够了,它的一单元自然也就是四字节了。
UTF-8 的 8 指的就是最小为 8 位一个单元,也即一字节为一个单元,UTF-8 可以包含一个单元,二个单元,三个单元及四个单元,对应即是一,二,三及四字节。
public static void main(String[] args) throws UnsupportedEncodingException { String A = "hi你是XXX"; System.out.println(A.length()); String B = ""; // 这个就是那个音符字符,只不过由于当前的网页没支持这种编码,所以没显示。 String C = "\uD834\uDD1E";// 这个就是音符字符的UTF-16编码 System.out.println(C); System.out.println(B.length()); // codePointCount其实就是代码点数的意思,也就是一个字符就对应一个代码点数。 System.out.println(B.codePointCount(0,B.length())); // java 获取系统中默认的编码 两种方法 System.out.println(System.getProperty("file.encoding")); System.out.println(Charset.defaultCharset()); char[] cs = A.toCharArray(); System.out.println(Arrays.toString(cs)); byte[] bUTF = A.getBytes(StandardCharsets.UTF_8); System.out.println(Arrays.toString(bUTF)+"--长度--:"+bUTF.length); byte[] bGBK = A.getBytes("GBK"); System.out.println(Arrays.toString(bGBK)+"--长度--:"+bGBK.length);}
9、创建格式化字符串
输出格式化数字可以使用 printf() 和 format() 方法。
String 类使用静态方法 format() 返回一个String 对象而不是 PrintStream 对象。
String 类的静态方法 format() 能用来创建可复用的格式化字符串,而不仅仅是用于一次打印输出。
// %d 输出整型的十进制 %x 输出十六进制System.out.printf("浮点型变量的值为 " + "%f, 整型变量的值为 " + " %d, 字符串变量的值为 " + "is %s", floatVar, intVar, stringVar);// 或者 String fs;fs = String.format("浮点型变量的值为 " + "%f, 整型变量的值为 " + " %d, 字符串变量的值为 " + " %s", floatVar, intVar, stringVar);
String#equals() 和 Object#equals() 区别:String 中的 equals 方法是被重写过的,比较的是 String 字符串的值是否相等。 Object 的 equals 方法是比较的对象的内存地址。
Object
public boolean equals(Object obj) { return (this == obj);}
String
public boolean equals(Object anObject) { if (this == anObject) { return true; } if (anObject instanceof String) { String anotherString = (String)anObject; int n = value.length; if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { if (v1[i] != v2[i]) return false; i++; } return true; } } return false; }
对象比较:
class Cat { public Cat(String name) { this.name = name; } private String name; public String getName() { return name; } public void setName(String name) { this.name = name; }} Cat c1 = new Cat("叶痕秋"); Cat c2 = new Cat("叶痕秋"); System.out.println(c1.equals(c2)); // falseString s1 = new String("叶子"); String s2 = new String("叶子"); System.out.println(s1.equals(s2)); // true // 以上结果解析:因为 Object 是采用==比较,而 String、Integer 等重写了 equals,比较的是值。String s = "Hello";final String s0 = "Hello";final String s1 = s;String s2 = "world";String str = s + "world"; // 运行时常量String str1 = s0 + "world"; // 编译期常量--编译期间会常量折叠// str2为运行时常量。虽然字符串s1被final修饰,但是初始化时没有使用编译期常量,因此它也不是编译期常量String str2 = s1 + "world";String str3 = "Helloworld";String str4 = "Hello" + "world"; // 编译期常量--编译期间会常量折叠,引用的是常量池中的 Helloworld。String str5 = new String("Helloworld");// str6 为运行时常量。虽然也被声明为 final 类型,并且在声明时就已经初始化,但使用的不是常量表达式final String str6 = UUID.randomUUID().toString() + "Hydra";final String str7 = "Hello" + "Hydra"; // 编译期常量String str8 = s + s2; //编译器会自动进行优化,生产一个新的字符串对象// String 的==比较的时引用是否相同(堆中内存地址是否相当)System.out.println(str == str4); // falseSystem.out.println(str1 == str4); // trueSystem.out.println(str2 == str4); // falseSystem.out.println(str3 == str4); // trueSystem.out.println(str4 == str5); // falseSystem.out.println(str8 == str3); // false == 比较的是引用是否相同// String、Integer 等重写了 equals,比较的是值。System.out.println(str.equals(str5)); // trueSystem.out.println(str.equals(str4)); // trueSystem.out.println(str1.equals(str3)); // trueSystem.out.println(str2.equals(str3)); // trueSystem.out.println(str5.equals(str3)); // true// intern()是判断字符串在常量池中是否有,有则返回,没有就创建在返回String str1 = new String("123");//再常量池创一个“123”对象,遇到 new 在堆内存创建一个对象,并返回堆中的对象引用String str2 = "123";//因为之前常量池中能找到“123”的对象,所以直接将引用返回,不创建新的String str3 = str1.intern();//若常量池中包含了 str1 字符串“123”,有则直接返回引用,否则就在池中先创建一个在返回池中的对象引用System.out.println((str1 == str2) +","+ (str3 == str2))//输出 false,trueString str4 = new String("234");String str5 = new String("234");String str6 = str4.intern();String str7 = str5.intern();System.out.println((str4 == str5) +","+ (str6 == str7));//输出 false ture
Java 语言规范要求 equals 方法具有下面的特性:
1)自反性:对于任何非空引用 x,x.equals(x)应该返回 true。
2)对称性:对于任何引用 x 和 y,当且仅当 y.equals(x)返回 true,x.equals(y)也应该返回 true。
3)传递性:对于任何引用 x、y 和 z,如果 x.equals(y)返回 true,y.equals(z)返回 true,x.equals(z)也应该返回 true。
4)一致性:如果 x 和 y 引用的对象没有发生变化,反复调用 x.equals(y)应该返回同样的结果。
5)对于任意非空引用 x,x.equals(null)应该返回 true。
hashCode 介绍:
hashCode的作用是获取哈希码,也称为散列码;它实际上是返回一个 int 整数。这个哈希码的作用是确定该对象在哈希表中的索引位置。hashCode定义在 JDK 的 Object 中,Java 中任何类都包含 hashCode函数。
散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象,提高具有哈希结构的容器的效率)
为什么要有 hashCode:(以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode)
对象加入 HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对象加入的位置,看该位置是否有值,如果没有,HashSet 会假设对象没有重复出现。但是如果发现有值,这时会调用 equals()方法来检查两个对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样就大大减少了 equals 的次数,相应就大大提高了执行速度。
1、数据可变和不可变
char 数组改 byte 数组修改的好处:单位时间内,减少空间->减少触发 GC 的次数->减少 Stop the world activity 的次数->提高系统性能。
2、线程安全
String 中的对象是不可变的,也可以理解为常量,线程安全。 StringBuilder 是线程不安全的,效率较高;而 StringBuffer 是线程安全的,效率较低。 比较 append() ,StringBuffer 有同步锁,而 StringBuilder 没有。
@Override public synchronized StringBuffer append(Object obj) { toStringCache = null; super.append(String.valueOf(obj)); return this; } @Override public StringBuilder append(String str) { super.append(str); return this; }
3、 相同点
StringBuilder 与 StringBuffer 有公共父类 AbstractStringBuilder。
最后,操作可变字符串速度:StringBuilder > StringBuffer > String。
总结:
其它知识点:
被 final 关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。因此,final 关键字修饰的数组保存字符串并不是 String 不可变的根本原因,因为这个数组保存的字符串内容是可变的(不变的是引用地址)。
String 真正不可变的原因:
答案:一个。
为什么源代码中字符串拼接的操作,在编译完成后会消失,直接呈现为一个拼接后的完整字符串呢?
因为在编译期间,应用了编译器优化中一种被称为常量折叠(Constant Folding)的技术,会将编译期常量的加减乘除的运算过程在编译过程中折叠。编译器通过语法分析,会将常量表达式计算求值,并用求出的值来替换表达式,而不必等到运行期间再进行运算处理,从而在运行期间节省处理器资源。
编译期常量的特点就是它的值在编译期就可以确定,且要完整满足以下要求,才可能是一个编译期常量:
上面的前两条比较容易理解,需要注意的是第三和第四条。从上图中可以看出:
通过 javap -verbose xx.class 命令再看一下编译后的字节码文件中的常量池区域:
可以看到常量池中只有一个 String 类型的常量 helloHydra,而 str6 对应的字符串常量则不在此区域。对编译器来说,运行时常量在编译期间无法进行折叠,编译器只会对尝试修改它的操作进行报错处理。
通过 javap -c 类名对字节码文件进行反编译,可以看到编译器同样会进行优化:虽然我们在代码中没有显示的调用 StringBuilder,但是在字符串拼接的场景下,Java 编译器会自动进行优化,新建一个 StringBuilder 对象,然后调用 append 方法进行字符串的拼接。最后调用了 StringBuilder 的 toString 方法,生成了一个新的字符串对象,而不是引用的常量池中的常量。
另外值得一提的是,编译期常量与运行时常量的另一个不同就是是否需要对类进行初始化,下面通过两个例子进行对比:
可以看到在添加了 final 修饰后,两次运行的结果是不同的,这是因为在添加 final 后,变量 a 成为了编译期常量,不会导致类的初始化。另外,在声明编译器常量时,final 关键字是必要的,而 static 关键字是非必要的,上面加 static 修饰只是为了验证类是否被初始化过。
答案:两个或一个都有可能。"Java"对象存放在字符串常量缓冲区,常量"Java"不管出现多少遍,都是缓冲区中的那一个。new String 每写一遍,就创建一个新的对象,它使用常量"Java"对象的内容来创建出一个新 String 对象。如果以前就用过"Java",那么这里就不会创建"Java"了,直接从缓冲区拿,这时创建了一个 StringObject;但如果以前没有用过"Java",那么此时就会创建一个对象并放入缓冲区,这种情况它创建两个对象。
另外:String s = "Java"与 String s3 = new String("Java")是不一样的。前者 Java 虚拟机会将其分配到常量池中,创建字符串对象时,JVM 首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。而后者则被分到堆内存中,首先在编译类文件时,"Java"常量字符串将会放入到常量结构中,在类加载时,"Java"将会在常量池中创建;其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的"Java"字符串,在堆内存中创建一个 String 对象,最后 s3 将引用 String 对象。