Java 字符串常量池、字符串比较/拼接问题、String类的不可变性

@


Java中用于处理字符串常用的有三个类:

1、java.lang.String

2、java.lang.StringBuffer

3、java.lang.StrungBuilder

相同点: 都是final类, 不允许被继承;

不同点:

  • StringBuffered/StringBuilder 都继承自抽象类AbstractStringBuilder
    (实现了Appendable, CharSequence接口),可以通过append()、insert()等方法进行字符串的操作
  • String实现了三个接口: Serializable、Comparable、CarSequence,
    String的实例可以通过compareTo方法进行比较
    StringBuilder/StringBuffer只实现了两个接口Serializable、CharSequence
  • StringBuffer是线程安全的(Synchronized 加锁),可以不需要额外的同步用于多线程中
    StringBuilder不是线程安全的,但是效率比StringBuffer高

本篇主要讨论String类型

1.字符串的比较

1. 1 字符串常量池

字符串常量池(以下简称常量池/字符串池)的存在意义:实际开发中,String类是使用频率非常高的一种引用对象类型。但是不断地创建新的字符串对象,会极大地消耗内存。因此,JVM为了提升性能和减少内存开销,内置了一块特殊的内存空间即常量池,以此来避免字符串的重复创建。JDK 1.8 后,常量池被放入到堆空间中。

字符串常量池的特点是有且只有一份相同的字面量,如果有其它相同的字面量,JVM则返回这个字面量的引用地址,如果没有相同的字面量,则在字符串常量池创建这个字面量并返回它的引用地址。

字符串常量池是全局共享的,故也称全局字符串池。字符串池中维护了共享的字符串对象,这些字符串不会被垃圾收集器回收。

1.1.1 字符串常量池在Java内存区域的存放位置?

在 JDK6.0及之前版本,字符串常量池是放在 Perm Gen区 (也就是方法区) 中,方法区与堆分离。
由于方法区的内存空间太小,在 JDK 7.0版本,字符串常量池被移到了堆中。

1.1.2 字符串常量池是如何实现的?

在 HotSpot 虚拟机里实现的 String Pool 功能的是一个 StringTable 类,它是一个 Hash 表,默认值大小长度是1009;这个 StringTable 在每个 HotSpot 虚拟机的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了 StringTable 上。

在 JDK 6.0中,StringTable 的长度是固定的,长度就是1009,因此如果放入 String Pool 中的String类字符串非常多,就会造成哈希冲突,导致链表过长,当调用 String.intern()时会需要到链表上一个一个查找,从而导致性能大幅度下降。

在 JDK7.0中,StringTable的长度可以通过参数指定:
-XX:StringTableSize=66666

1.2 String 类型的比较方式

若直接使用“==”进行比较对象,则比较的是两个对象的引用地址;

若使用str1.equals(str2)方法进行比较,由于String类内部已经覆盖Object类中的equals()方法,实际比较的是两个字符串的值。

  • 比较原理:
    先判断对象地址是否相等,若相等则直接返回true;
    若不相等再去参数判断括号内传入的参数是否为String类型的:若不是字符串将最终返回false;若是字符串,再依次比较所有字符是否一样。
// 源码
public boolean equals(Object anObject) {
	if (this == anObject) {
    	return true;
    }
    if (anObject instanceof String) {
        String aString = (String)anObject;
        if (coder() == aString.coder()) {
        	return isLatin1() ? StringLatin1.equals(value, aString.value) : StringUTF16.equals(value, aString.value);
        }
     }
     return false;
}

1.3 String 的创建方式

1.3.1 直接使用“=”进行字面量赋值

String str_01 = "aa";
String str_02 = "aa";
System.out.println(str_01 == str_02);

.java文件编译后得到 .class文件,里面包含了类的信息,其中有一块叫做常量池(Constant Pool)的区域 (.class常量池和内存中的 String Pool 并不是同一个东西),.class文件常量池主要存储的就包括字面量,字面量包括类中定义的常量,由于String是不可变的(String为什么是不可变的?)所以字符串“Hello”就存放在 .class文件常量池里。

当程序调用类时,.class文件被解析到内存中的方法区,同时 .class文件中的常量池信息会被加载到运行时常量池。但 String类不会这样,“aa”会在堆区中创建一个对象,同时会在 String Pool 存放一个它的引用。

此时类刚刚被加载,main函数中的 str_01 并没有被创建,而“aa”对象已经创建在于堆中。
当主线程开始创建 str_01 变量时,JVM会去 String Pool 中查找是否有 与“aa”相等的String,如果相等就把在字符串池中“Hello”的引用赋值给str。如果找不到相等的字符串,就会在堆中新建一个字符串对象,同时把引用驻留在字符串池,再把引用赋给str。

因此,当用字面量赋值的方法创建字符串时,无论创建多少次,只要String的值相同,它们所指向的都是堆中的同一个对象。

// result
true

1.3.2 使用“new”关键字创建新对象

String str_01 = new String("xyz");
String str_02 = new String("xyz");
System.out.println(str_01 == str_02);

当利用new关键字去创建字符串时,之前的加载过程是一样的,只是在运行时无论 String Pool 中有没有与 String构造器内传入参数面值相等的字符串,都会在堆中新开辟一块内存,创建一个字符串对象。

因为本质是调用了 String类的构造器方法 public String(String original){...},所以在堆中一定会创建一个字符串对象。

故使用"new"关键字创造对象主要分为三步:

  1. 首先会在堆中创建一个字符串对象;
  2. 判断 String Pool 是否存在与构造器参数中的字符串值相等的常量;
  3. 如果 String Pool 中已有这样的字符串值存在,则直接返回堆中的字符串实例对象地址,赋值给栈中的变量;如果不存在,会先创建一个这样的字面量在 String Pool 中,同时把引用驻留在字符串池,再返回它的引用,赋值给栈中的变量。
    ( String Pool 中存的对象是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的。)
// result
false

1.3.3 intern() 方法返回的引用地址

String str_01 = new String("abc");
String s1_Intern = str_01.intern();
String str_02 = "abc";
String s2_Intern = str_01.intern();
String str_03 = new String("abc");
String s3_Intern = str_03.intern();

System.out.println(str_01 == str_02);
System.out.println(str_02 == str_03);
System.out.println(s1_Intern == str_02);
System.out.println(s2_Intern == str_02);
System.out.println(s3_Intern == str_02);

String str_04 = "cba";
String str_05 = new String("cba");
String s5_Intern = str_05.intern();
System.out.println(str_04 == str_05);
System.out.println(str_04 == s5_Intern);

intern() 方法能使一个位于堆中的字符串在运行期间动态地加入到 String Pool 中( String Pool 的内容是程序启动的时候就已经加载好了),如果 String Pool 中有该对象对应的字面量,则返回该池中该字面量的引用,否则,存储一份该字面量的引用到 String Pool 中并将该引用返回,但这个引用实际指向堆中的对象。

// result
false
false
true
true
true

false
true

2. 字符串类的可变性与不可变性

String类字符串的本质:
事实上,Java中并没有内置的字符串类型,而是在Java 标准类库中提供了一个类名恰好为"String"的预定义类,所有由双引号""括起来的内容都是String类的一个实例。

  • String类的说明,在API中有明确的给出解释:
    The {@code String} class represents character strings. All string literals in Java programs, such as {@code "abc"}, are implemented as instances of this class.

String在Java 9 之前使用 private final char[] str 保存数据,从Java 9 开始及后续版本使用private final byte[] value保存数据,并有一个coder标志符,来表示数据是用哪一种编码保存的,以方便之后的方法进行区分对待。同时,这个改变使字符串能够占用更少的空间,由原来实现的数组是2个字节长度的char类型数组更改为byte数组后,每个元素只有1个字节的长度。

值得注意的是,JVM并不一定把字符串实现为代码单元序列。Java 9 后,只包含单字节代码单元的字符串使用bytes数组实现,其他字符串则由char数组实现。

String类型的不可变性指的是内存地址不可变,如果将一个字符串变量重新赋值,则本质上是改变了其引用对象。 这一点也可以从API的文档中可以看出,String类是final的,并且没有提供可以修改字符串的方法,所有看似修改了字符串的方法实际上是创建并返回了一个新的字符串。

String a = "hello";
System.out.println(a.hashCode());
a = "hello";
System.out.println(a.hashCode());
String b = a.toUpperCase();
System.out.println(a==b);
// result
99162322
103196
false

StringBuffer 类型和StringBuilder 类型也被final修饰,但这两种类型的字符串定义好后可虽不会创建新的内存地址,但可以进行值改变。

StringBuilder a = new StringBuilder();
System.out.println(a.hashCode());
a.append("Hello");
a.append("World");
System.out.println(a.hashCode());
// result
1395089624
1395089624

3. 字符串的相加/拼接

3.1 字符串与非字符串类型的相加/拼接

String类中的valueOf(Object obj)方法可以将任意一个对象转换为字符串类型。

// 源码
public static String valueOf(Object obj) {
  return (obj == null) ? "null" : obj.toString();
}

String类中,重载了+与+=运算,这也是Java中唯一重载的两个运算符。

两个字符串相加即是字符串的拼接,在进行拼接时,会先调用valueOf(Object obj)方法将其为字符串类型,再进行拼接。从源码可以看出,如果字符串为null,会将其转换为字面值为"null"的字符串。

String s = null;
s = s + "World";
System.out.println("Hello " +s);
// result: Hello nullWorld

因此在进行字符串拼接时,初始字符串应该设置成空字符串"",而非null。

3.2 两个String类型对象相加/拼接原理

在字符串间使用加法运算时:

  • 若是常量字符串相加,如: "AB"+"CD",则是编译优化。
    凡是单独使用双引号" "引用起来的内容直接拼接时,均会被编译优化,编译时就已经确定其值,即为拼接后的值。
  • 若是字符串变量相加,如:
    String temp1 = "AB";
    String temp2 = "CD";
    String str = temp1 + temp2;
    则是在底层调用了StringBuilder类中的构造方法、append()方法和toString()方法来辅助完成:
    String str = new StringBuilder().append(temp1).append(temp2).toString();
		String str1 = "ABCD";
		String str2 = "AB" + "CD";
		String str3 = "A" + "B" + "C" + "D";
		String temp1 = "AB";
		String temp2 = "CD";
		String str4 = temp1 + temp2;
		// String str4 = new StringBuilder().append(temp1).append(temp2).toString();
		
		String temp = "AB";
		String str5 = temp + "CD";
		// String str5 = new StringBuilder(String.valueOf(temp)).append("CD").toString();
		
		System.out.println(str1 == str2);
		System.out.println(str1 == str3);
		System.out.println(str1 == str4);
		System.out.println(str1 == str5);
// result
true
true
false
false

4. final类型的String类字符串

首先要明确:final关键字修饰的变量,若为基本数据类型则值无法改变,若是引用数据类型则引用地址不能改变。

4.1 final修饰静态类变量

4.1.1 直接声明字段时赋值

public class test {
	public static final String str1 = "abc";
	public static final String str2 = "def";
	public static void main(String[] args) {
		String str3 = str1 + str2;
		String str4 = "abcdef";
		System.out.println(str3 == str4);
	}
}
  • 如果使用final和static同时修饰一个字段,并直接定义时赋值,并且这个字段是基本数据类型或者String类型的,那么编译器在编译这个字段的时候,会在类的字段属性表中一个ConstantValue属性,在 JVM加载类的准备阶段变量就会被初始化为ConstValue属性所指定的常量值。

  • 如果该field字段并没有被final修饰,或者不是基本数据类型或者String类型,那么将在静态区域或构造方法中进行初始化。

这里的str1和str2都是static和final类型的,因此在编译时,在 JVM加载类的准备阶段会被赋值了,相当于一个常量,当执行Strings str3 = str1 + str2 的时候,str3已经是"abcdef"常量了,已被添加在常量池中,所以地址是相等的。

// result
true

4.1.2 静态区域内初始化

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

虽然s1和s2都是final类型,但却是在静态区域完成初始化,其本质仍然是变量,只不过变量的值不能改变。因此在拼接时仍然会调用 StringBuilder类中的构造方法、append()方法和toString()方法来创建新的字符串s3,返回的是新字符串s3在堆中的地址,所以与s4不相等。

// result
false

4.2 final修饰成员变量

由于修饰的是成员变量,所以其初始化一定发生在构造器中,本质仍然是值固定的变量。在拼接时同样会创建新的字符串。

public class Test {

	private final String str1 = "AB";
	private final String str2;
	
	public Test(){
		str2 = "AB";
		}
	
	public static void main(String[] args) {
		Test t = new Test();
		String s = "ABC";
		String s1 = t.str1 + "C";
		String s2 = t.str2 + "C";
		System.out.println(s == s1);
		System.out.println(s == s2);
		System.out.println(s1 == s2);
	}
}
/* result */
// false
// false
// false

参考内容:

  1. Java-String常量池的知识点你知道多少?-结合jdk版本变更 by hz90s
  2. "Core Java Volume I - Fundamentals" (11 Edition) by Cay S. Horstmann
  3. static final的初始化与赋值问题 by 一毛六ABV
  4. Java中的常量池(字符串常量池、class常量池和运行时常量池) by shoshana
  5. 理解Java字符串常量池与intern()方法 by 没课割绿地

If you have any question, please let me know, your words are always welcome.
新人入坑,如有错误/不妥之处,欢迎指出,共同学习。

posted @ 2020-07-11 21:36  Johnson1z  阅读(231)  评论(0编辑  收藏