Java / Java编码规范 一文弄懂String的所有小秘密 2021-03-09 [TOC] # 简介 首先,我们先了解一下java中变量的语义,在java中变量的语义有两种: 原始类型的变量是值语义(value),也就是说,你给一个原始类型变量赋值,就改变了这个数据值本身。 对象类型的变量是引用语义,也就是说,给一个对象类型的变量赋值只是让它指向另一个对象,但不改变原来引用的那个对象的值。 然后,我们了解一下String的特性以及java对于Sting特别的处理方式: String是java中非常常用的一个对象类型。那么在使用String时到底需要注意哪些地方呢?接下来本文将会一一讲解。 # 术语 - 字符串字面量:指双引号引住的一系列字符 - 常量池:用来保存字符串对象引用的容器 - 元空间:本质和永久代类似,都是对JVM规范中方法区的实现,区别在于元空间并不在虚拟机中,而是使用本地内存。 # 知识点: - 字符串字面量和常量称为“静态字符串” - 字面量和常量的连接在编译期间执行,优化为一个静态字符串 - 动态字符串:字符串运算结果,或者连接结果或者 new运算创建的字符串,等运行期间创建的字符串不参与静态优化 # JDK1.8虚拟机 ![](/uploads/1/image/public/202103/20210309173653_b12n633eo8.jpg) > 说明: 在JDK1.8中,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中,而从永久代移出的字符串常量池,静态常量,在更换了方法区实现后,并没有顺势进入到元空间,而是**运行时常量池和字符串常量池被移动到了堆中**(逻辑上还是属于方法区,但实际上存放在堆内存中)。 扩展:为什么移除永久代? 1. 字符串存在永久代中,容易出现性能问题和内存溢出。 2. 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。 3. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。 # String的特性 String类是final的,不可被继承。 String类的本质是字符数组char[](JDK9之前[JDK9之后为字节数组Byte[]]), 并且其值不可改变 Java运行时会维护一个字符串常量池(全局字符串池/string pool/string literal pool),存放的内容是在类加载完成,经过验证,准备阶段之后在堆中生成的字符串对象实例的引用值(**记住:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的**),具体看[1] 创建字符串的方式很多,归纳起来有三类:一、直接指定。比如String s2 = "abc";二、使用new关键字创建字符串,比如String s1 = new String("abc");三、使用串联生成新的字符串。比如String s3 = "ab" + "c"; 具体看[2] >说明: [1]在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个哈希表,里面存的是字符串字面量(也就是我们常说的用双引号括起来的字符串)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了”字符串字面量”的身份。这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。 [2]"abc"就表示一个字符串对象。而x是"abc"对象的引用;通过“+”串联后会生成新的字符串;new关键字会创建新对象 # String对象创建的原理 原理1:当使用任何方式来创建一个字符串对象s=X时,Java运行时(运行中JVM)会拿着这个X在String池中找是否存在内容相同的字符串对象,如果不存在,则在池中创建一个字符串s,否则,不在池中添加。 原理2:Java中,只要使用new关键字来创建对象,则一定会(在堆区或栈区)创建一个新的对象。 原理3:使用直接指定或者使用纯字符串串联来创建String对象,则仅仅会检查维护String池中的字符串,池中没有就在池中创建一个,有则罢了!但绝不会在堆栈区再去创建该String对象。 原理4:使用包含变量的表达式来创建String对象,则不仅会检查维护String池,而且还会在堆栈区创建一个String对象。 # String是不可变的 String是不可变的,官方的说法是immutable或者constant。 定义一个string类型的变量的三种方式: - A、赋值操作: ```java String a="abc"; String b="abc"; ``` >理解如下: 1. 首先对于Java虚拟机来说,"abc"是字符串字面量,在JDK 7之后,这个字符串字面量是存储在java heap(堆)中的。而在JDK 7之前是有个专门的方法区来存储的。 2. 然后将“abc”在堆中的引用赋值给a和b ![](/uploads/1/image/public/202103/20210309153007_psoejrwnw9.png) - B、new操作: ```java String c= new String("abc"); ``` >理解如下: 1. 同上,首先在java heap中创建了“abc”(如果不存在) 2. 然后调用String的构造函数创建一个“abc”实例对象 ```java public String(String original) { this.value = original.value; this.hash = original.hash; } ``` > 在构造函数中,String将底层的字符串数组赋值给value。 因为Array的赋值只是引用的赋值,所以上述new操作并不会产生新的字符串字面值。 但是new操作新创建了一个String对象c,并将“abc”对象在堆中的引用赋值给了c. 另外:String的不可变性还在于,String的所有操作都会产生新的字符串字面量(如果不存在)。原来的字符串是永远不会变化的。这样设计的好处就在于,它是线程安全的。任何线程都可以很安全的读取字符串。 - C、对象方法 ```java public class ImmutableStrings{ public static void main(String[] args){ String start = "Hello"; String end = start.concat(" World!"); System.out.println(end); } } // Output Hello World! ``` > 理解如下: 1. 首先对于Java虚拟机来说,"Hello"为字符串字面量,JDK7之前通过方法区来存储,JDK7之后存储在Java heap(堆)中。为了实现将“Hello”赋值给start变量,需要在堆中创建一个“Hello”对象 2. 然后将“Hello”对象的引用赋值给变量start。 3. 接着调用了“Hello”对象的concat(String)方法,然而concat(String)方法有如下的描述: ```java /** * Concatenates the specified string to the end of this string. * <p> * If the length of the argument string is {@code 0}, then this * {@code String} object is returned. Otherwise, a * {@code String} object is returned that represents a character * sequence that is the concatenation of the character sequence * represented by this {@code String} object and the character * sequence represented by the argument string.<p> * Examples: * <blockquote><pre> * "cares".concat("s") returns "caress" * "to".concat("get").concat("her") returns "together" * </pre></blockquote> * * @param str the {@code String} that is concatenated to the end * of this {@code String}. * @return a string that represents the concatenation of this object's * characters followed by the string argument's characters. */ public String concat(String str) { int otherLen = str.length(); if (otherLen == 0) { return this; } int len = value.length; char buf[] = Arrays.copyOf(value, len + otherLen); str.getChars(buf, len); return new String(buf, true); } ``` > 方法描述:将指定字符串连接在这个字符串的结尾。 如果长度为0,则返回这个字符串对象。**否则就创建一个新的字符串对象**,表示这个字符串序列由原字符串对象和参数字符串二者所表示的字符串序列拼接而成。 另外,"+"操作符,实际上是new了一个StringBuilder对象,然后调用append方法 - JDK9之前,String的底层其实是一个Char的数组 > private final char value[]; - JDK9之后,String的底层存储变成了Byte > private final byte[] value; **目的:是为了进行String压缩,减少内存占用量** 所有的String字面量比如”abc”都是String的实现。 # 线程安全与字符串常量池 字符串和Java中其他对象一样,**都是创建在堆中**。 **字符串常量池存在于堆中,只用来存储字符串字面值的引用**,因为字符串对象是不可变的,所以通过字符串常量池中字符串字面值的引用来“共享”同一个字符串字面量。 下面来看一个例子: ```java public class ImmutableStrings{ public static void main(String[] args){ String one = "someString"; String two = "someString"; String three = new String("someString"); System.out.println(one.equals(two)); System.out.println(one == two); System.out.println(one.equals(three)); System.out.println(one == three); } } // Output true true true false ``` > 原理理解: 1.当一个.java文件被编译成.class文件时,和所有其他常量一样,每个字符串字面量都通过一种特殊的方式被记录下来。 2.当一个.class文件被加载时(**注意加载发生在初始化之前**),JVM在.class文件中寻找字符串字面量。 3.然后,JVM会检查是否有相等的字符串在常量池中存放了堆中引用。 4.如果找不到,就会在堆中创建一个对象,然后将它的引用存放在池中的一个常量表中。 5.一旦一个字符串对象的引用在常量池中被创建,这个字符串在程序中的所有字面量引用都会被常量池中已经存在的那个引用代替 > 结论:在上面的例子中,常量池中只有一个“someString”字符串对象的引用,局部变量one和two都被赋予了同一个字符串对象的引用。 另外: String的equals方法,比较的是对象的value值是否相同,即是否包含相同的数据(“someString”) “==”操作符作用在String对象上,比较的是对象的引用是否相同,即只有两个引用相同时(指向的是同一个对象)才会返回true。 # 常见习题 - 示例一: ```java String str1 = "abc"; String str2 = new String("def"); String str3 = "abc"; String str4 = str2.intern(); String str5 = "def"; System.out.println(str1 == str3);//true System.out.println(str2 == str4);//false System.out.println(str4 == str5);//true ``` > 解析: 处理流程:首先上面程序经过编译之后,在该类的class常量池中存放一些符号引用,然后类加载之后,将class常量池中存放的符号引用转存到运行时常量池中,然后经过验证,准备阶段之后,在堆中生成字符串字面值的实例对象(也就是上例中str1所指向的”abc”实例对象),然后将这个对象的引用存到全局String Pool中,也就是StringTable中,最后在解析阶段,要把运行时常量池中的符号引用替换成直接引用,那么就直接查询StringTable,保证StringTable里的引用值与运行时常量池中的引用值一致,大概整个过程就是这样了。 > 回到上面的那个程序: 首先,在堆中会有一个”abc”实例,全局StringTable中存放着”abc”的一个引用值; 然后,在运行第二句的时候会生成两个实例,一个是”def”的实例对象,并且StringTable中存储一个”def”的引用值,还有一个是new出来的一个”def”的实例对象,与上面那个是不同的实例; 其次,解析str3的时候查找StringTable,里面有”abc”的全局驻留字符串引用,所以str3的引用地址与之前的那个已存在的相同; 其次,str4是在运行的时候调用intern()函数,返回str2字面值”def”在StringTable中的引用值,如果没有就将str2的引用值添加进去,在这里,StringTable中已经有了”def”的引用值了,所以返回上面在new str2的时候添加到StringTable中的 “def”引用值; 最后,str5在解析的时候就也是指向存在于StringTable中的”def”的引用值,那么这样一分析之后,下面三个打印的值就容易理解了。 总结: 全局常量池在每个VM中只有一份,存放的是字符串常量的引用值。 class常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用。 运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。 - 示例二(关于定义String的堆栈问题): ```java String s =new String();//分析堆与栈,是先定义S,还是先new string() String str1 = "abc"; System.out.println(str1 == "abc"); ``` >步骤: 1) 栈中开辟一块空间存放引用str1; 2) String池中开辟一块空间,存放String常量"abc"; 3) 引用str1指向池中String常量"abc"; 4) str1所指代的地址即常量"abc"所在地址,输出为true; ```java String str2 = new String("abc"); System.out.println(str2 == "abc"); ``` >步骤: 1) 栈中开辟一块空间存放引用str2; 2) 堆中开辟一块空间存放一个新建的String对象"abc"; 3) 引用str2指向堆中的新建的String对象"abc"; 4) str2所指代的对象地址为堆中地址,而常量"abc"地址在池中,输出为false; ```java String str3 = new String("abc"); System.out.println(str3 == str2); ``` >步骤: 1) 栈中开辟一块空间存放引用str3; 2) 堆中开辟一块新空间存放另外一个(不同于str2所指)新建的String对象; 3) 引用str3指向另外新建的那个String对象 ; 4) str3和str2指向堆中不同的String对象,地址也不相同,输出为false; ```java String str4 = "a" + "b"; System.out.println(str4 == "ab"); ``` >步骤: 1) 栈中开辟一块空间存放引用str4; 2) 根据编译器合并已知量的优化功能,池中开辟一块空间,存放合并后的String常量"ab"; 3) 引用str4指向池中常量"ab"; 4) str4所指即池中常量"ab",输出为true; ```java final String s = "a"; //注意:这里s用final修饰,相当于一个常量 String str5 = s + "b"; System.out.println(str5 == "ab"); ``` >步骤:同四 ```java String s1 = "a"; String s2 = "b"; String str6 = s1 + s2; System.out.println(str6 == "ab"); ``` >步骤: 1) 栈中开辟一块中间存放引用s1,s1指向池中String常量"a", 2) 栈中开辟一块中间存放引用s2,s2指向池中String常量"b", 3) 栈中开辟一块中间存放引用str6, 4) s1 + s2通过StringBuilder的最后一步toString()方法还原一个新的String对象"ab",因此堆中开辟一块空间存放此对象, 5) 引用str6指向堆中(s1 + s2)所还原的新String对象, 6) str6指向的对象在堆中,而常量"ab"在池中,输出为false ```java String str7 = "abc".substring(0, 2); ``` > 步骤: 1) 栈中开辟一块空间存放引用str7, 2) substring()方法还原一个新的String对象"ab"(不同于str6所指),堆中开辟一块空间存放此对象, 3) 引用str7指向堆中的新String对象, ```java String str8 = "abc".toUpperCase(); ``` > 步骤: 1) 栈中开辟一块空间存放引用str6, 2) toUpperCase()方法还原一个新的String对象"ABC",池中并未开辟新的空间存放String常量"ABC", 3) 引用str8指向堆中的新String对象 ```java String s="abc"; String s1=s; System.out.println(s1=="abc"); s=s+"hello"; System.out.println(s1=="abc"); System.out.println(s=="abc"); ``` > 步骤: 1)栈中开辟一块空间存放s; 2)Sting池中开辟一块空间用于存放"abc",栈中开辟一块空间存放变量s1; 3)系统输出true,在堆中开辟一块空间用于存放"abchello"; 4)引用s指向堆中的"abchello"; 5)系统输出true,然后输出false; # 参考文档 - [Java中几种常量池的区分](http://tangxman.github.io/2015/07/27/the-difference-of-java-string-pool/) - [java中String s="abc"及String s=new String("abc")详解](https://blog.csdn.net/qq_19740405/article/details/50635417) - [字符串常量池深入解析](https://blog.csdn.net/weixin_40304387/article/details/81071816) - [一文弄懂String的所有小秘密](http://www.flydean.com/string-all-in-one/)