一. 认识String类
写在开头,string本质是一个char数组。string中的字符串是由一个个char组成,形成一个不可变的char数组,string对象负责管理这个char数组的生命周期,string类中大多数方法也是针对这个char数组的操作
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
/** use serialVersionUID from JDK 1.0.2 for interoperability */
private static final long serialVersionUID = -6849794470754667710L;
public native String intern();
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;
}
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
}
1.1 声明string的俩种方式
使用字面量
String str = "test"
使用new关键字
String str = new String("test")
以下是一个demo
public class Demo2 {
public static void main(String[] args) {
String s = new String("test1");
String s2 = "test1";
String s3 = "test1";
boolean result = s == s2; // false
boolean result1 = s2 == s3; // true
System.out.println(result);
System.out.println(result1);
}
}
如上有一段代码:
字符串s声明的是对象,字符串s2、s3声明的是字面量。
那么,使用这俩种方式声明的字符串有什么区别呢
new关键字
使用 new() 的方式来创建一个 String 对象,执行后会在字符串常量池和堆中各创建一个对象,即创建两个对象,若字符串常量池中已存在了"test1"对象,则只会在Heap中创建,即只创建了一个对象。字面量声明
JVM 首先将会对我们赋的值到 String Pool 中进行查找,如果找到的话,就返回已经存在这个值的引用,如果没找到,就创建一个字符串放入到常量池中。
1.2 俩种方式的不同点和共同点
上小节中的 == 比较的是内存地址,结果显示的result是false,result1是true。
基于JDK8的JVM内存模型图,网上有很多类似的图,但我觉得画的都不是很准确,下面的图我觉得能清晰说明string模型的结构
下面,我们用实际结果来证明上图的正确性
1.2.1 使用字面量声明的字符串指向的内存地址相同,并且字符串中的char数组地址也是一致
我们要证明的是以下俩个点:
- 字面量声明的字符串内存地址都是相同的
- 字面量声明的字符串中char数组地址也是一致的
1.2.1.1 证明内存地址相同
我们先看第一张图:
我们看到s2和s3的hashCode相同,是否能说明s2和s3指向的对象地址一样呢。注意,此处的hashcode使用的是JVM的底层方法处理,调用的是native方法也即是c++方法去处理的。
问题:那么,底层hashCode一致是否能代表内存地址一致呢?
答:不一定,我们可以从GC层面去思考,垃圾回收时(复制算法,整理算法)都要发生对象移动,都要改变对象的内存地址。但hashCode又不能变化,那么该值一定是不会和内存地址强挂钩,并且被保存在对象的某个地方了(对象头)。
那么,我们要如何证明这个内存地址是一样的呢,其实java天然提供这个功能,使用==默认比较的就是内存地址,我们可以看到s2和s3==比较的结果是true的。因此可以直接证明s2和s3的内存地址是一致的。
问题已经证明了,但是我们这边有几个其他点可以延伸:
问题1: 原生hashCode能不能表示内存地址?
答:其实是可以的,请移步这篇文章 https://blog.csdn.net/sinat_42457748/article/details/123961244,具体如下:
随机生成值
获取对象的实际内存地址
返回1,用于灵敏度测试
自增 4- 第二种的变种
xorshift算法,有兴趣可以看一下https://en.wikipedia.org/wiki/Xorshift,相对比较复杂,也可以看下知乎大佬解释https://www.zhihu.com/question/27951358
原生hashcode是有几种生成策略的,并且通过JVM参数可以指定。jdk1.8前,默认hashcode为0,jdk1.8后,默认为5
问题2: 为什么不使用string方法中的hashCode方法而是使用System.identityHashCode去呈现示例
答: 因为string中的hashcode方法是重写,天然带有二次封装性,无法区分说明例子,下面看张图
我们可以看到,如果使用String中的hashcode去,那么s和s2、s3都是相同的。这明显不符合常理,但是又可以解释的通,因为string的hashCode方法是计算String内部char数组内容是否一致。
1.2.1.2 证明同一字面量string中char数组也是一致的
如何证明string内部final属性的char数组也是一致的的呢,用代码不好直接去做验证,因为char数组是private的,string没有直接提供访问方法。
除非我们使用反射进行访问。但是这里我们没这么麻烦,使用idea可以直接进行查看,如下图。
我们可以看到,s2和s3的同一个字符串"test1"内部所对应的value数组内存地址是一样的,因此,我们可以判定我们所要证明的结论。
结论:同一字面量string中char数组也是一致的
1.2.2 new关键字声明string也会复用数组char
1.2图中,展示的new String("test1")的char数组指向常量池中的引用,这个我们如何证明呢?
一样的,我们通过代码还需要进行反射才能证明,不如直接通过idea来去看
我们可以清楚的看到,s和s2、s3的char数组内存地址是一样的,是复用了这个char数组。
因此我们基于以上总结画出了1.2的图示。
二、深入了解String
2.1 来看几个问题
问题1:这段代码创建了几个对象?
String str1 = new String("test1");
答案:
俩个,"test1"对象和String对象
解释:public class demo.Demo2 minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #6.#22 // java/lang/Object."<init>":()V #2 = Class #23 // java/lang/String #3 = String #24 // test1 #4 = Methodref #2.#25 // java/lang/String."<init>":(Ljava/lang/String;)V #5 = Class #26 // demo/Demo2 #6 = Class #27 // java/lang/Object #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 LocalVariableTable #12 = Utf8 this #13 = Utf8 Ldemo/Demo2; #14 = Utf8 main #15 = Utf8 ([Ljava/lang/String;)V #16 = Utf8 args #17 = Utf8 [Ljava/lang/String; #18 = Utf8 s #19 = Utf8 Ljava/lang/String; #20 = Utf8 SourceFile #21 = Utf8 Demo2.java #22 = NameAndType #7:#8 // "<init>":()V #23 = Utf8 java/lang/String #24 = Utf8 test1 #25 = NameAndType #7:#28 // "<init>":(Ljava/lang/String;)V #26 = Utf8 demo/Demo2 #27 = Utf8 java/lang/Object #28 = Utf8 (Ljava/lang/String;)V { public demo.Demo2(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 9: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Ldemo/Demo2; public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=3, locals=2, args_size=1 0: new #2 // class java/lang/String 3: dup 4: ldc #3 // String test1 6: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V 9: astore_1 10: return LineNumberTable: line 11: 0 line 22: 10 LocalVariableTable: Start Length Slot Name Signature 0 11 0 args [Ljava/lang/String; 10 1 1 s Ljava/lang/String; }
问题2: 输出结果是true还是false?
String str1 = new String("test1");
String str2 = "test1";
System.out.println(str1 == str2);
根据1.2解释答案很明显是false,因为两个变量指向的地址不同,一个指向字符串常量池,一个指向堆上的对象,而==比较的就是地址。
问题3: 输出结果是true?
String str1 = new String("test1");
str1.intern();
String str2 = "test1";
System.out.println(str1 == str2);
intern官网注释:
/** * Returns a canonical representation for the string object. * <p> * A pool of strings, initially empty, is maintained privately by the * class {@code String}. * <p> * When the intern method is invoked, if the pool already contains a * string equal to this {@code String} object as determined by * the {@link #equals(Object)} method, then the string from the pool is * returned. Otherwise, this {@code String} object is added to the * pool and a reference to this {@code String} object is returned. * <p> * It follows that for any two strings {@code s} and {@code t}, * {@code s.intern() == t.intern()} is {@code true} * if and only if {@code s.equals(t)} is {@code true}. * <p> * All literal strings and string-valued constant expressions are * interned. String literals are defined in section 3.10.5 of the * <cite>The Java™ Language Specification</cite>. * * @return a string that has the same contents as this string, but is * guaranteed to be from a pool of unique strings. */
译文:
返回字符串对象的规范表示。
一个字符串池,最初是空的,由 String 类私下维护。
当调用 intern 方法时,如果池中已经包含一个等于该 String 对象的字符串,由 equals(Object) 方法确定,则返回池中的字符串。否则,将此 String 对象添加到池中并返回对该 String 对象的引用。
由此可见,对于任何两个字符串 s 和 t,当且仅当 s.equals(t) 为真时,s.intern() == t.intern() 才为真。
所有文字字符串和字符串值的常量表达式都是实习的。字符串文字在 Java™ 语言规范的第 3.10.5 节中定义。
答案:
false
下面画一个图来表示:
基于jdk1.8
s = new String("test1")后,在堆中生成了一个对象,同时也在字符串常量池中生成了一个字符串常量。
为什么这么能肯定,因为假设没有在常量池中生成"test1",那么在调用s.intern后,常量池中存在的就不是新对象,而是s的引用(通过官网文档),但事实我们上面的结果是false,说明常量池中存在的不是s的引用,而是新的字符串。
s2获取到的字符串和指向的地址是由s在new String初始化过程中在常量池中生成的字符串,s.intern()操作后并没有改变任何情况,这时候,声明s2字面量的时候,直接是从常量池中拿到的字符串。
因此s指向的是堆中地址,s2指向的是常量池中地址。俩者引用地址不一样。
问题4: 基于问题3的改造,如何让问题3变成true?
String str3 = new String("test") + new String("1");
str3.intern();
String str4 = "test1";
System.out.println(str3 == str4);
答案:
我们再画一张图
2.2 为什么string是不可变的
- 不可变的原因
- String的主要成员变量char value[]是private final类型的;
- String被声明为final class,是典型的Immutable类;
- 不可变的好处
可以缓存 hash 值
因为 String 的 hash 值经常被使用,例如 String 用作 HashMap 的 key。 不可变的特性可以使得 hash 值也不可变,因此只需要进行一次计算。String Pool 的需要
如果一个 String 对象已经被创建过了,那么就会从 String Pool 中取得引用。 只有 String 是不可变的,才可能使用 String Pool。
安全性
String 经常作为参数,String 不可变性可以保证参数不可变。例如在作为网络连接参数的情况下如果 String 是可变的,那么在网络连接过程中,String 被改变,改变 String 对象的那一方以为现在连接的是其它主机,而实际情况却不一定是。线程安全
String 不可变性天生具备线程安全,可以在多个线程中安全地使用。
2.3 是否能够变更String
我们通过2.2知道string是不可变更的,那么事实上,string到底能不能变更,实际,我们使用相应技术是可以实现变更的,但也只能变更特定场景下的。
我们至少有俩种方式可以实现变更:
2.3.1 使用反射改变
我们可以使用反射动态修改string类中的char数组达到修改string的目的
2.3.2 使用代理
我们也可以代理String类,达到动态修改内部数据的目的,但是因为String类是final的,原则上来说不允许使用cglib代理,cglib要求类是public修饰符的。因此,我们还需要使用javaagent技术来修改String类的访问修饰符。
补充:虽然技术上可以实现修改string,但是final类型的string,在编译期会被优化,直接以常量的形式嵌入到代码中,因此,只要我们能够逃脱编译期优化,使用代理和反射能够使得字符串得到变更。
三、了解JVM常量池
推荐文章:【JVM】Java 8 中的常量池、字符串池、包装类对象池
3.1 Class文件常量池
class文件是一组以字节为单位的二进制数据流,在Java代码的编译期间,我们编写的Java文件就被编译为.class文件格式的二进制数据存放在磁盘中,其中就包括class文件常量池。
class文件常量池主要存放两大常量:字面量和符号引用。
-
字面量:字面量接近java语言层面的常量概念
文本字符串,也就是我们经常申明的:public String s = "abc";中的"abc"
用final修饰的成员变量,包括静态变量、实例变量和局部变量:public final static int f = 0x101;,final int temp = 3;
而对于基本类型数据(甚至是方法中的局部变量),如int value = 1常量池中只保留了他的的字段描述符int和字段的名称value,他们的字面量不会存在于常量池。
-
符号引用:符号引用主要设涉及编译原理方面的概念
类和接口的全限定名,也就是java/lang/String;这样,将类名中原来的".“替换为”/"得到的,主要用于在运行时解析得到类的直接引用
字段的名称和描述符,字段也就是类或者接口中声明的变量,包括类级别变量和实例级的变量
方法中的名称和描述符,也即参数类型+返回值
3.2 运行时常量池
当Java文件被编译成class文件之后,会生成上面的class文件常量池,JVM在执行某个类的时候,必须经过加载、链接(验证、准备、解析)、初始化的步鄹,运行时常量池则是在JVM将类加载到内存后,就会将class常量池中的内容存放到运行时常量池中,也就是class常量池被加载到内存之后的版本,是方法区的一部分。
在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,也就StringTable,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。
运行时常量池相对于class常量池一大特征就是具有动态性,Java规范并不要求常量只能在运行时才产生,也就是说运行时常量池的内容并不全部来自class常量池,在运行时可以通过代码生成常量并将其放入运行时常量池中,这种特性被用的最多的就是String.intern()
3.3 字符串常量池
在JDK6.0及之前版本,字符串常量池存放在方法区中,在JDK7.0版本以后,字符串常量池被移到了堆中了。至于为什么移到堆内,大概是由于方法区的内存空间太小了。在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个Hash表,默认值大小长度是1009;这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了StringTable上。
在JDK6.0中,StringTable的长度是固定的,长度就是1009,因此如果放入String Pool中的String非常多,就会造成hash冲突,导致链表过长,当调用String#intern()时会需要到链表上一个一个找,从而导致性能大幅度下降;在JDK7.0中,StringTable的长度可以通过参数指定。
字符串常量池设计思想:
字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度地影响程序的性能
-
JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化
为字符串开辟一个字符串常量池,类似于缓存区
创建字符串常量时,首先查看字符串常量池是否存在该字符串
存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中
-
实现的基础
实现该优化的基础是因为字符串是不可变的,可以不用担心数据冲突进行共享
运行时实例创建的全局字符串常量池中有一个表,总是为池中每个唯一的字符串对象维护一个引用,这就意味着它们一直引用着字符串常量池中的对象,所以,在常量池中的这些字符串不会被垃圾收集器回收
四、字符串常量池和String的发展
4.1 方法区的演变
Hotspot中方法区的变化:
这是针对于HotSpot而言
版本 | 变化 |
---|---|
JDK1.6及之前 | 有永久代,字符串常量池、静态变量存放在永久代上 |
JDK1.7 | 有永久代,但已经逐步”去永久代“,字符串常量池、静态变量以及保存在堆中了。 |
JDK1.8及之后 | 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中 |
在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类(列如jsp),同理string常量池的迁移也是一样。会经常出现致命错误:java.lang.OutOfMemoryError:PermGen
而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。对永久代进行调优是很困难的。
4.2 java9的String
参考文章:JDK9为何要将String的底层实现由char[]改成了byte[]?
string演变
有关 Java 9 的 String
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
private final byte coder;
@Native static final byte LATIN1 = 0;
@Native static final byte UTF16 = 1;
static final boolean COMPACT_STRINGS;
}
一直到 Java 8,Strings 在 Java 中使用字符数组进行存储的,同时使用的是 UTF-16 字符集,因此每一个字符将会使用 2 字节的内存。
从 Java 9 开始,Java 提供了一个叫做压缩字符(Compact Strings)的存储概念。
这个存储将会针对字符串使用 char[] 和 byte[] 中字符编码,这个将会与你需要存储的内容有关。
简单来说就是从 Java 9 开始,String 将会根据存储内容的不同来使用不同的存储格式,只会在必要的时候才会使用 UTF-16 编码,这种设计将会显著降低 String 对内存的使用,并且能够让来让垃圾清理程序(Garbage Collector)更有效率的工作。