String分析

String 这个类是我们在写 Java 代码中用得最多的一个类,没有之一,今天我们就讲讲它。

简介

String 并不是基本数据类型,而是一个对象,并且是不可变的对象。

查看源码就会发现 String 类为final 型的(当然也不可被继承),而且通过查看 JDK 文档会发现几乎每一个修改 String 对象的操作,实际上都是创建了一个全新的 String 对象。

String 类实现了 SerializableComparableCharSequence接口,被 final 修饰。内部维护了一个字符数组。

常用方法

字符串比较

1
2
3
4
5
6
7
8
9
10
11
12
13
== // 判断内容与地址是否相同

boolean equals(Object anObject) // 判断两个字符串内容是否相同
boolean equalsIgnoreCase(String anotherString) // 忽略大小写,判断两个字符串内容是否相同,底层调用了regionMatches方法

boolean contentEquals(CharSequence cs) // 判断字符序列和字符串内容是否相同
boolean contentEquals(StringBuffer sb) // 判断StringBuffer和字符串内容是否相同,实际上调用的是contentEquals方法

int compareTo(String anotherString) // 按照字典大小比较两个字符串的大小
int compareToIgnoreCase(String str) // 忽略大小写,按照字典大小比较两个字符串的大小,使用忽略大小写比较器

boolean regionMatches(boolean ignoreCase, int toffset, String other, int ooffset, int len)
// 判断两个字符串部分内容是否相同,ignoreCase:是否忽略大小写,toffset:子字符串的偏移量,ooffset:参数字符串中子字符串的偏移量,len:比较的子字符串的长度

字符串查找

1
2
3
4
5
6
7
8
9
10
11
char charAt(int index) // 返回索引上的字符,索引从0开始

int indexOf(int ch) // 从字符串开始检索字符(Unicode 码) ch,并返回第一次出现的位置,未出现返回-1。
int indexOf(int ch,int fromIndex) // 从字符串的第fromIndex个字符开始检索字符(Unicode 码) ch,未出现返回-1。
int lastIndexOf(int ch) // 从字符串检索字符(Unicode 码) ch最后一次出现的位置。
int lastIndexOf(int ch, int fromIndex) //从字符串的第fromIndex个字符检索字符(Unicode 码) ch最后一次出现的位置。

boolean startsWith(String prefix, int toffset) // 判断此字符串从指定索引开始的子字符串是否以指定前缀开始
boolean startsWith(String prefix) // 判断此字符串是否以指定前缀开始。

boolean endsWith(String suffix) // 判断此字符串是否以指定后缀结尾。

字符串截取与替换

1
2
3
4
5
6
7
8
String substring(int beginIndex) // 返回一个新的字符串,是从beginIndex开始到length-1的串。
String subString(int beginIndex,int endIndex)------返回一个新的字符串,是从beginIndex开始到endIndex-1的串。
CharSequence subSequence(int beginIndex, int endIndex) // 返回从beginIndex开始到endIndex-1的字符序列

String replace(char oldChar, char newChar) // 将字符串中的oldChar字符替换为newChar
String replace(CharSequence target, CharSequence replacement) // 将字符串中的target字符序列替换为replacement序列
String replaceFirst(String regex, String replacement) // 使用replacement替换第一个通过regex匹配到子串。
String replaceAll(String regex, String replacement) // 使用replacement替换所有通过regex匹配到子串。

字符串其他常用方法

1
2
3
4
5
6
7
8
9
int length() // 获取字符串长度
boolean isEmpty() // 判断字符串是否为空
boolean contains(CharSequence s) // 判断字符串是否包含字符序列 s
String concat(String str) // 字符串拼接
String trim() // 字符串去掉首尾空白
String toUpperCase(Locale locale) // 字符串转大写
String toLowerCase(Locale locale) // 字符串转小写
String[] split(String regex) // 字符串分割
String[] split(String regex, int limit) // 字符串分割,多了结果阈值参数

创建方式

第一种方式是在常量池中直接拿对象,第二种是在堆内存空间创建一个新的对象。只要使用new方法,就需要创建新的对象。

1
String str = new String("Freya"); // 这句话创建了两个对象。

首先,字符串“Freya”放入常量池,然后 new 了一个字符串“Freya”放入 Java 堆。字符串常量”Freya”在编译期就已经确定放入常量池,而 Java 堆上的”Freya”是在运行期初始化阶段才确定。然后 Java 栈中的 str 指向了 Java 堆中的 “Freya”。

字符串常量池

字符串常量池是 JVM 实例全局共享的,全局只有一个。字符串常量池及到一个设计模式,叫“享元模式”,顾名思义 就是共享元素模式。也就是说:一个系统中如果有多处用到了相同的一个元素,那么我们应该只存储一份此元素,而让所有地方都引用这一个元素。

  • 直接使用双引号声明的 String 对象直接存储在字符串常量池。
  • 不是双引号声明的,可以使用 String.intern() 方法,这是一个Native方法。如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void main(String[] args) {
String a = "Freya"; //常量池中的对象
String b = "Freya"; //常量池中的对象
String c = new String("Freya");
String d = a.intern();
String e = "Freya" + " 17"; //常量池中的对象
String f = " 17";
String g = a + f; //在堆上创建的新的对象
String h = "Freya 17"; //常量池中的对象
System.out.println(a == b); //true
System.out.println(a == c); //false
System.out.println(a == d); //true
System.out.println(e == g); //false
System.out.println(e == h); //true
System.out.println(g == h); //false
}

注意:尽量避免多个字符串拼接,因为这样会重新创建对象。如果需要改变,可以使用StringBuilder或者StringBuffer。

String 对象的不可变性

打开 String 类源码,可以看到一句话:

Strings are constant; their values cannot be changed after they are created. String buffers support mutable strings. Because String objects are immutable they can be shared.

意思是 String 是个常量,创建之后就是不可变的。不可变的意思是不能改变对象内的成员变量,包括基本数据类型变量的值不能改变,引用类型的变量不能指向其他的对象,引用类型指向的对象的状态也不能改变。

String 对象不可变性是如何实现的?

从JDK 9之后,value的类型由 char[] 数组变成了 byte[] 数组。目的是为了节省空间,提高String的性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence
{
/** The value is used for character storage. */
private final char value[];
/** The offset is the first index of the storage that is used. */
private final int offset;
/** The count is the number of characters in the String. */
private final int count;
/** Cache the hash code for the string */
private int hash; // Default to 0
....
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length); // deep copy操作
}
...
public char[] toCharArray() {
// Cannot use Arrays.copyOf because of class initialization order issues
char result[] = new char[value.length];
System.arraycopy(value, 0, result, 0, value.length);
return result;
}
...
}

从源码可以看出:

  • String 类被 final 修饰,不可继承。
  • String内部所有成员都设置为私有变量。
  • 不存在value的setter方法。
  • 将value和offset设置为final。
  • 当传入可变数组value[]时,进行copy而不是直接将value[]复制给内部变量。
  • 获取value时不是直接返回对象引用,而是返回对象的copy。

String 对象不可变性的优缺点

优点

  1. 字符串常量池的需要
    字符串常量池可以将一些字符常量放在常量池中重复使用,避免每次都重新创建相同的对象、节省存储空间。但如果字符串是可变的,此时相同内容的String还指向常量池的同一个内存空间,当某个变量改变了该内存的值时,其他遍历的值也会发生改变。所以不符合常量池设计的初衷。

  2. 线程安全考虑
    同一个字符串实例可以被多个线程共享。这样便不用因为线程安全问题而使用同步。字符串自己便是线程安全的。

  3. 类加载器要用到字符串,不可变性提供了安全性,以便正确的类被加载

    譬如你想加载java.sql.Connection类,而这个值被改成了myhacked.Connection,那么会对你的数据库造成不可知的破坏。

  4. 支持hash映射和缓存
    因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。

缺点

如果有对String对象值改变的需求,那么会创建大量的String对象。

String对象是否真的不可变

虽然 String 对象将 value 设置为 final,并且还通过各种机制保证其成员变量不可改变。但是还是可以通过反射机制的手段改变其值。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public static void main(String[] args) {
//创建字符串"Hello World", 并赋给引用s
String s = "Hello World";
System.out.println("s = " + s); //Hello World

//获取String类中的value字段
Field valueFieldOfString = null;
try {
valueFieldOfString = String.class.getDeclaredField("value");
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
//改变value属性的访问权限
valueFieldOfString.setAccessible(true);

//获取s对象上的value属性的值
char[] value = new char[0];
try {
value = (char[]) valueFieldOfString.get(s);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
//改变value所引用的数组中的第5个字符
value[5] = '_';
System.out.println("s = " + s); //Hello_World
}

打印结果为:

1
2
3
4
s = Hello World
s = Hello_World

Process finished with exit code 0

其他相关内容

substring 方法

在 JDK 1.6中,当调用 substring 方法的时候,会创建一个新的 String 对象,但是这个 String 的值仍然指向堆中的同一个字符数组。这两个对象中只有 count 和offset 的值是不同的。如果你有一个很长很长的字符串,但是当你使用 substring 进行切割的时候你只需要很短的一段。这可能导致性能问题,因为你需要的只是一小段字符序列,但是你却引用了整个字符串(因为这个非常长的字符数组一直在被引用,所以无法被回收,就可能导致内存泄露)。在JDK 1.6中,一般用以下方式来解决该问题,原理其实就是生成一个新的字符串并引用他。

1
x = x.substring(x, y) + ""

以上问题在jdk 7+中得到解决。在jdk 7+ 中,substring方法会在堆内存中创建一个新的数组。其使用new String创建了一个新字符串,避免对老字符串的引用。从而解决了内存泄露问题。

String.valueOf和Integer.toString的区别

我们有三种方式将一个int类型的变量变成呢过String类型,那么他们有什么区别?

1
2
3
4
int i = 5;
String i1 = "" + i; //其实是String i1 = (new StringBuilder()).append(i).toString();,首先创建一个StringBuilder对象,然后再调用append方法,再调用toString方法。
String i2 = String.valueOf(i); //调用 Integer.toString(i)
String i3 = Integer.toString(i);

replaceFirst、replaceAll、replace 的区别

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
String a = "Hello";

a.replace("e","f");
System.out.println(a); // Hello

a = a.replace("e","f");
System.out.println(a); // Hfllo
}
  • replace(CharSequence target, CharSequence replacement) ,用 replacement 替换所有的 target ,两个参数都是字符串。
  • replaceAll(String regex, String replacement) ,用 replacement 替换所有的 regex 匹配项,regex 很明显是个正则表达式,replacement 是字符串。
  • replaceFirst(String regex, String replacement) ,基本和 replaceAll 相同,区别是只替换第一个匹配项。

注意

  • replace 替换的只能是字符或字符串形式。

  • replaceAll() 和 replaceFirst() 是基于正则表达式的替换。

  • replaceAll() 和 replace() 是替换所有的,而 replaceFirst() 仅替换第一次出现的。

  • 如果 replaceAll() 和 replaceFirst() 所用的参数据不是基于正则表达式的,则与 replace() 替换字符串的效果是一样的。

  • 执行了替换操作后,源字符串的内容是没有发生改变的。

switch 对字符串的支持

switch 中只能使用整型,其他数据类型都是转换成整型之后在使用switch的。比如byteshortchar(ASCII码是整型)以及int

字符串进行switch的实际是哈希值,然后通过使用 equals 方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。因此它的性能是不如使用枚举进行 switch 或者使用纯整数常量,但这也不是很差。因为Java编译器只增加了一个equals方法,如果你比较的是字符串字面量的话会非常快。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class switchDemoString {
public static void main(String[] args) {
String str = "world";
switch (str) {
case "hello":
System.out.println("hello");
break;
case "world":
System.out.println("world");
break;
default:
break;
}
}
}

对代码进行反编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class switchDemoString
{
public switchDemoString()
{
}
public static void main(String args[])
{
String str = "world";
String s;
switch((s = str).hashCode())
{
default:
break;
case 99162322:
if(s.equals("hello"))
System.out.println("hello");
break;
case 113318802:
if(s.equals("world"))
System.out.println("world");
break;
}
}
}