了解String的那些事


String 是Java编程中的引用类型,不属于基本类型,默认值为null,在Java中是用来创建于操作字符串。
源码如下所示:

public final class String
    implements java.io.Serializable, Comparable, 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

说明:String的底层是由char 数组实现的。我们创建的字符串都是由char数组保存的,由于使用final修饰,因此char数组的引用不可变,但并不代表数组内容不可变,因此为了实现真正的数组不可变,还加上了private修饰符。

问题1:从你的角度谈谈String 为什么要设计成不可变的?
本问题选自 从你的角度谈谈String 为什么要设计成不可变的?

何为不可变?
对于Java而言,除了基本类型,其余都是对象。在《JAVA并发编程实践》一书中对 "不可变"给出了一个粗略的定义:
①其状态不能再创建后再修改
②所有域都是final类型。
③在构造函数构造对象期间,this引用没有泄漏。
注:一,在第二点中,一个对象所有域都是final类型,该对象也有可能是可变对象。因为final关键字只是限制对象的域的引用不可变,但是无法限制通过该引用去修改其内部状态。二,不可变对象内部域并不一定全部声明是final类型,比如String类中的有一个名为 hash 的域并不是final类型,这是因为①String类型的惰性计算 hashcode并存储在hash域中,通过其他final类型域来保证每次的hashcode计算结果必定是相同的。②每次对String类型的所有改变内部存储结构的操作都会new一个新的String对象。

不可变带来的好处
安全性
由于String的不可变,那么在多线程的情况下对String的任何操作都是安全的。
类加载器要用到字符串,不可变形提供了安全性,以便正确的类被加载。

使用常量池节省空间
只有当字符串不可变时,字符串常量池才有可能实现。当不同的字符串常量指向池中同一个字符串时,将会大大的降低heap使用空间,String的intern方法才可能实现。

缓存hashcode
因为字符串的不可变,在它创建的时候hashcode就被缓存了,不需要重新计算,这使得Map中的键很适合做Map集合的键,字符串的处理速度要快过其他的键对象。
对于String 源码的 hash有这样的说明

private int hash;//this is used to cache hash code.

不可变带来的坏处
由于不可变,对字符串使用完后就扔,所以会创建大量的对象垃圾。
密码应该存储在字符数组中而不是String中

参考 为什么Java中的密码优先使用 char[] 而不是String?
由于String在Java中是不可变的,如果你将密码以明文的形式保存成字符串,那么它将一直留在内存中,直到垃圾收集器把它清除。而由于字符串被放在字符串缓冲池中以方便重复使用,所以它就可能在内存中被保留很长时间,而这将导致安全隐患,因为任何能够访问内存(memory dump内存转储)的人都能清晰的看到文本中的密码,这也是为什么你应该总是使用加密的形式而不是明文来保存密码。由于字符串是不可变的,所以没有任何方式可以修改字符串的值,因为每次修改都将产生新的字符串,然而如果你使用char[]来保存密码,你仍然可以将其中所有的元素都设置为空或者零。所以将密码保存到字符数组中很明显的降低了密码被窃取的风险。
当然只使用字符数组也是不够的,为了更安全你需要将数组内容进行转化。 建议使用哈希的或者是加密过的密码而不是明文,然后一旦完成验证,就将它从内存中清除掉。

问题2 String str = "Hello"与String str = new String("Hello")创建几个对象
问题相关:https://www.zhihu.com/question/29884421
Java虚拟机管理的内存最主要的有三块
1 堆(Heap):最大的一块内存。存在对象实例和数组。全局共享。
2 栈(Stack):全称“虚拟机栈(JVM Stacks)”。存放基本型,对象引用。线程私有。
3 方法区(Method Area):“类”被加载后的信息,常量,静态变量存放在这里。全局共享。在HotSpot里也叫“永生代”,但两者不能等同。

这三块区域的详细划分如下所示
注图为JDK1.6及之前的版本,1.7版本以后将HotSpot的Interned Strings 移到了Heap堆中,1.8版本彻底取消了永久代

了解String的那些事_第1张图片
Java虚拟机的内存分布.jpg

主要看图中的Stack栈区里的“局部变量表(Local Variables)”和“操作数栈(Operand Stack)”。因为栈是线程私有的,每个方法被执行的时候都会创建一个“栈帧(Stack Frame)”。而每个战阵里对应的都维护着一个局部变量表和操作数栈。我们常说的基本类型和对象引用存在栈里,其实就是存在局部变量表中。而操作数栈是线程实际的操作台。

中间的这个非堆(Non-Heap)可以粗略地理解为非堆里包含了永生代,而永生代里又包括了方法区。

和String最相关的是非堆(Non-Heap)的“运行时常量池(Run-time Constant Pool)”。它是每个类私有的。每个class文件里的“常量池”在类被加载器加载后,就会映射存放在这个地方。另一个相关的是“字符串常量池(String Pool)”,但和运行时常量池不是一个概念。字符串常量是全局共享的,处于Interned String的位置,可以理解为在永生代里,方法区外。String.intern()方法,字符串驻留之后,引用就放在这个String Pool。

分析完JVM虚拟机内存划分后,我们来看class文件的信息划分
String有两种赋值方式,第一种是通过“字面量”赋值。比如下面代码

String str = "Hello";  ①

第二种是通过new关键字创建新对象。比如下面这样

String str = new String("Hello");  ②

在编译class文件后,除了版本、字段、方法、接口等描述信息外,还有一个也叫“常量池(Constant Pool Table)”的东西。这个常量池与内存中的常量池不一样。class文件里的常量池主要存两个东西:“字面量(Literal)”和“符号引用量(Symbolic References)”。字面量就包括类中定义的一些常量,因为String是不可改变的,由final关键字修饰过,所以代码①中的”Hello“字符串,就作为字面量(常量)写在class的常量池里。

在程序运行Class文件时,class文件的信息就会被解析到内存的方法区里。class文件里常量池中大部分数据会被加载到”运行时常量池(Run-time Constant Pool)“。但是,这个“进入”过程,并不会直接把所有类中定义的常量全部都加载进来,而是会做个比较,如果需要加到字符串常量池中的字符串已经存在,那么就不需要再把字符串字面量加载进来了。

在类加载阶段,JVM会在堆中创建对应这些class文件常量池中的字符串对象实例,并在字符串常量池中驻留其引用,具体会在resolve阶段进行。但在resovle阶段并不会立即执行这些动作,JVM规范里明确指定resolve阶段可以是lazy的。

代码①中”Hello“的一个引用会被存到同样在非堆(Non-Heap)的字符串常量池(String Pool)里,而”Hello“本体还是和所有对象一样,创建在Heap堆区,具体是在新生代的Eden区。因为一直有一个引用驻留在字符串常量池,所以不会被GC清理掉。这个Hello对象会生存到整个线程结束。

等到主线程开始创建str变量的时候,虚拟机就会到字符串常量池找有没有equal("Hello")的String。如果有,那么就会在栈区当前栈帧的局部变量表里创建str变量,然后把字符串常量池里对Hello对象的引用复制给str变量。找不到的话,才会在Heap堆重新创建一个对象,然后把引用驻留到字符串常量区,然后把引用复制给栈帧的局部变量表中的str。

了解String的那些事_第2张图片
JVM内存引用分布.png

针对第一个问题,看一下代码

String str1 = "Hello";
String str2 = "Hello";
String str3 = "Hello";

三者的关系如下图所示

了解String的那些事_第3张图片
三者关系图.jpg

但是使用new关键字就不一样了

String str1 = "Hello";
String str2 = "Hello";
String str3 = new String("Hello");  
了解String的那些事_第4张图片
new与 =的三者关系图.jpg

这时候,str1和str2还是和之前一样。但是str3会因为new关键字在Heap堆申请一块全新的内存,来创建新对象。虽然字面还是"Hello",但是完全不同的对象,有不同的内存地址。注意,str3的Hello堆地址并不会加到String Table里去。

总结
①类似String a = "a"+"b"+"c" 基本等同于“abc”,不会产生中间垃圾对象,也就是说"a","b","c"最终会消失掉 。
②字符串与引用存在字符串池中,而对象存在堆内存中
③字符串常量池的作用是保存该字符串的引用与字符串,以防堆内存中的对象被GC回收
④方法里的private int i=1这种是局部变量。会在调用方法的时候进入栈区当前栈帧的局部变量表。
⑤String s = new String("xyz"); 在运行时涉及几个String实例?
答案:两个,堆中有个xyz的一号本体,然后字符串常量池里存的是这一号本体的地址,比如0xfff。然后new String("xyz")创建的是堆中的新对象,xyz的二号本体,二号本体在字符串常量池中无引用。

问题3 String a = ””、String b = null、String c = new String()三者的区别
答:””与new String() 表示已经new出一个对象,但是内部为空,且已经创建了对象的引用,是需要分配内存空间的。null 表示还没有new出一个对象,即该对象的引用还没创建,也没有分配内存地址。

我们来看看这道代码题来更好的理解上面的解释(jdk1.8.0_172)

public class Client {
   private static void isString(String str){
      if (str == null){
         System.out.println("null");
      }
      if (str.isEmpty()){
         System.out.println("isEmpty");
      }
      if (str.equals("")){
         System.out.println("\"\"");
      }
      System.out.println("-----------");
   }
   public static void main(String[] args) {
      String a = new String();
      String b = "";//没有空格
      String c = null;
      String d = " ";//有一个空格
      isString(a);
      isString(b);
      isString(c);
      isString(d);
      System.out.println(d.length());
      System.out.println(c+"adcd");
   }
}
---output---
isEmpty
""
-----------
isEmpty
""
-----------
Exception in thread "main" java.lang.NullPointerException
null
    at com.homework.demo_15.Client.isString(Client.java:11)
    at com.homework.demo_15.Client.main(Client.java:24)
-----------
1
nulladcd

分析:
String a = new String():a分配了内存空间但内部值为空,注意是有值但是值为空,称为绝对空;
String b = "";//没有空格:b分配了内存空间,是有值且内部值为空字符串,称为相对空;
String c = null:c没有分配内存空间且无值无内容,也就是没有被实例化。此对象还不存在。
String d = " ";//有一个空格:d分配内存空间,是有值且内部值是一个空格

根据输出结果,我们可以有以下总结
①判断一个String对象是否为空值,比如a与b判断是否为空,可以使用 str.equals("") 与 str.isEmpty()(内部实际调用length == 0 )。
②判断一个String对象是否为null(空对象),可以使用 str == null 判断,但是无法使用 str.isEmpty( )或 str.equals("") 判断,因为并未被实例化
③对于包含空格的字符串判断,str.equals("") 与 str.isEmpty()判断结果均为false,str.length() 的值为空格个数
④String c = null 与任何对象进行“+”操作,最后都会变成“null+对象.toString()”

问题4 如何理解String的intern方法?
参考问题:Java 中new String("字面量") 中 "字面量" 是何时进入字符串常量池的?

答:编译期生成的各种字面量和符号引用是运行时常量池中比较重要的一部分来源,但并不是全部。那么还有一种情况,可以在运行期像运行时常量池中增加常量,那就是String 的 intern 方法。

intern方法作用:如果常量池中已经有了这个字符串,那么直接返回常量池中它的引用,如果没有,那就将它的引用与字符串保存一份到字符串常量池,然后直接返回这个引用。

String str3 = new String("Hello");

如上述代码,在堆内存中创建“Hello”的对象,并且“Hello”字符串也在堆内存中。之所以运行该代码会产生两个对象,指的是在堆内存中有一个“Hello”的一号本体,字符串常量池里存的是一号本体的地址。new String("Hello")创建在堆中的新对象,这是“Hello”的二号本体,且这个二号本体是不会加到StringTable里去的,要是想加到里去,需要使用String的intern方法,这也就是为什么不推荐使用new创建String实例。

解释:什么叫StringTable

StringTable其实就是个简单的哈希表,是HotSpot VM里用来实现字符串驻留功能的全局数据结构。
如果用Java语法来讲,这个StringTable其实就是个HashSet --- 
它并不保留驻留String对象本身,而是存储这些被驻留的String对象的引用。

在遇到String类型常量时,resolve的过程如果发现StringTable已经有了内容匹配的java.lang.String的引用,则直接返回这个引用,反之,如果StringTable里尚未有内容匹配的String实例的引用,则会在Java堆里创建一个对应内容的String对象,然后在StringTable记录下这个引用,并返回这个引用出去。

根据intern的作用,我们来看看下面的代码(jdk1.8.0_172)

String a = "hello"; //hello对象1
String b = new String("hello").intern(); // hello对象2,将堆中新建的对象"hello" 存入字符串常量池
System.out.println(a == b);  // 输出 true 

在类加载阶段,第一句JVM会在堆中创建hello对象1的实例,并且引用会在字符串常量池中驻留。接下来第二句new 会在堆中新开辟一个内存空间创建hello对象2,然后再调用intern()方法,JVM会查找常量池中是否有相同Unicode的字符串常量,结果是有,那么就会返回hello对象1的实例在字符串常量池驻留的引用,因此输出为true。

再看这个代码(jdk1.8.0_172)

String s1=new String("he")+new String("llo");  //第一句
String s2=new String("h")+new String("ello");  
String s3=s1.intern(); //第三句
String s4=s2.intern(); //第四句
System.out.println(s1==s3); //输出true
System.out.println(s1==s4); //输出true

分析:运行到第一句时,会创建“he”与“llo”的对象,并且会保存引用到字符串常量池中,这就是这两个对象的一号本体。然后new String()创建二号本体,然后在“+”起来,内部是通过StringBuilder对象的append方法组合起来,再通过调用StringBuilder对象的toString方法得到一个String对象(内容是hello,注意这个toString方法会new一个String对象),并将它赋值给s1。注意:并不会把hello的引用放入字符串常量池中。

再看第三句,调用intern方法后发现字符串常量池中并没有对应的字符串“hello”,它会把第一句中的“hello”对象引用保存到字符串常量池,然后返回这个引用,这个引用赋值给s3,此时s1与s3指向同一个对象

第四句中,调用intern方法后发现字符串常量池中有对应的字符串“hello”,直接返回该引用给s4,因此s1与s4指向同一个对象。

问题5 如何理解《深入理解java虚拟机》第二版中对String.intern()方法的讲解中所举的例子?

String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1);  //输出 true

String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2); // 输出 false

答:Java标准库在JVM启动过程中加载的部分,可能里面就有类引用"java"字符串字面量,这个字面量被初次引用的时候就会被intern,加入到字符串常量池中去。具体是在初始化 sun.misc.Version 类时被放进字符串常量池StringTable里的

答案摘录自https://www.zhihu.com/question/51102308/answer/124441115

你可能感兴趣的:(了解String的那些事)