String都快用烂了,结果还是被面试官吊打

前言

最近想到了之前面试问我的一道字符串相关的面试题, 即使平时都快用烂了, 一到面试还是容易犯难, 这不痛定思痛, 彻底解决这个隐患。又到金三银四了, 分享给大家。开始之前,先看看面试官爱问的几个问题。

String是线程安全的吗?为什么?

String Pool有了解吗? 它的底层是如何实现的?

String a = new String(“java”); 会创建几个对象?

String为什么要设计成不可变的?

String在JDK9中底层实现为什么要从char[]切换为byte[]?

String#intern()方法用过吗? 底层逻辑对内存有什么影响?

在经历了对自己的灵魂拷问后, 总结了一下, 对于java的String考察集中于这四个最主要的点:

  • 不可变性
  • 字符串常量池
  • String#intern()
  • 底层实现的变化

在真正进入正文之前, 我摘取了笔试面试中常出的几道题, 附上答案, 结合下文会进行完整的解释。

题目1:

String a = "a" + "b";
String b = "ab";
System.out.println(a == b); //true

题目2:

扫描二维码关注公众号,回复: 13147944 查看本文章
String s1 = "a" + "b";
String s2 = "a";
String s3 = s2 + "b";
System.out.println(s1 == s3); //false

题目3:

String s1 = "ab";
final String s2 = "a";
final String s3 = "b";
String s4 = s2 + s3;
String s5 = ("a" + s3).intern();
System.out.println(s1 == s4);  //true
System.out.println(s1 == s5);  //false

如果对3道题目内部的处理机制还存在疑惑, 那么下面的内容将会每一步的操作都进行深入剖析。最后补充一下’=='与equals()的区别:

‘==’ 比较的是变量(栈)内存中存放的对象的(堆)内存地址,用来判断两个对象的地址是否相同,即是否是指相同一个对象。需要注意的是:

  • 比较的是操作符两端的操作数是否是同一个对象。
  • 两边的操作数必须是同一类型的(可以是父子类之间)才能编译通过。
  • 引用类型比较的是地址(即是否指向同一个对象),基本数据类型比较的是值,值相等则为true,如:int a=10 与 long b=10L 与 double c=10.0都是相同的(为true),因为他们都指向地址为10的堆。

‘equals()’用来比较的是两个对象是否相等,由于所有的类都是继承自java.lang.Object类的,在Object中的基类中定义了一个equals的方法,这个方法的初始行为是比较对象的内存地址,但String类中重写了equals方法, 比较的是字符串的内容 ,而不再是比较类在堆内存中的存放地址了。

总结:在没有重写equals方法的情况下,他们之间的比较还是基于他们在内存中的存放位置的地址值的,因为Object的equals方法也是用双等号()进行比较的,所以比较后的结果跟双等号()的结果相同。String类中重写了equals方法,变成了字符串内容的比较。

一 String 的不可变性

官方文档中如此描述String:

Strings are constant; their values cannot be changed after they are created.

String是一个常量, 一旦创建后它们的值将不能再被修改。String的底层是使用char[]来实现的, 并且都使用了final关键字来修饰。

修饰类: 表示这个类将不能被继承。也就是说String是没有子类的!
修饰成员变量时: 系统不会给其赋值(如引用类型为null),需要在定义变量名时赋值,或者在构造器中赋值。

但是这么操作下呢,

String s = "a";
s = "a" + "b";
System.out.println(s);   //ab

打印出来的结果是"ab"。说好了不可变的, 结果却不是"a"。

不妨看一眼字节码指令:

 0 ldc #2 <a>   //从常量池中加载 #2 值为a的字符串
 2 astore_1    //赋值到 索引位置为1的变量s
 3 ldc #3 <ab>  //从常量池中加载 #3 值为ab的字符串
 5 astore_1     赋值到 索引位置为1的变量s
 6 getstatic #4 <java/lang/System.out>
 9 aload_1
10 invokevirtual #5 <java/io/PrintStream.println>
13 return

从字节码指令集中不难发现, 变量s先后经历了两次赋值, 并且两次地址的指向不同(一次是 #2, 一次是 #3),每一次对 s的赋值, 都在内存中开辟了一个新的空间并将新的地址指向给s, 所以官方提供的那句话是没有问题的, 值没有变化只是地址的指向发生了变化, 最终呈现的s是一个全新的字符串。

接下来再以一个例子详细的阐明String的不可变性:

public static void main(String[] args) {
    
    
        String str = "abc";
        changeString(str);
        System.out.println("str = " + str);

    }
    public static void changeString(String str2) {
    
    
        str2 += "aaa";
    }

打印出来的结果依然是 abc。changeString()这个方法依然没有改变str的值, str2指向str后随即操作了str2 += "aaa"最终指向了"abcaaa"这样的一个字符串。

那么String为什么要设计成不可变的呢?

  1. 多线程环境下操作String是线程安全的, 不用担心过程中被篡改;
  2. String字符串创建后hash值只需计算一次;
  3. String字符串被创建后可以放入String Pool中, 正因为其不可变性, 才可以有String Pool的实现。

最后, 简要概括一下, String字符串的不可变性是指字符串一旦被创建,就会在堆上生成这个字符串的实例,并且不可被改变。任何外部

方法都不会改变字符串本身,而只会创建一个新的字符串。

二 字符串常量池的变化与理解

在JDK6中, JVM中方法区的实现是永久代(Perm区), 字符串常量池也是在存放在这里。在JDK7中字符串常量池移到了堆区, 在JDK8及以后,永久代被移除, 被元空间(MetaSpace) 替代。

字符串常量池的底层实现是HashTable, 同时意味着如果HashTable设置的长度过小, 哈希冲突产生的频率就会更高, 链表就会很长, 当调用String#intern()时会需要到链表上一个一个找,从而导致性能大幅度下降。在JDK8中,默认的长度是60013, 而在JDK6中, 默认长度仅仅只有1009。这个长度大小可以通过-XX:StringTableSize=60013参数来进行调整。

不妨思考一下, 为什么字符串常量池要从永久代移到堆区呢?

Java虚拟机规范中并没有强制要求方法区中一定要实现GC,在HotSpot虚拟机中,虽然方法区中实现了GC, 但相对于堆区它的GC频率是很低的; 此外方法区的内存分配比较小, 字符串放入堆中, 则可以及时进行GC回收。

那真正创建字符串的时候在内存中是如何开辟空间呢?

下面通过几个示例来说明下不同场景下声明的字符串在内存中的存储情况。

示例1:

String a = "123";
String b = new String("123");
System.out.println(a == b);  //false

通过字面量声明的字符串会在字符串常量池中开辟空间。而通过new String("123")直接声明的字符串会首先在堆区开辟空间,若String Pool已经存在"123"则不生成, 若不存在则会再在String Pool中存储一份, 而 b 最终依然是指向堆区中内存地址, 故而上述结果输出的是false。这也是面试中经常会问到的一个经典问题。

注: 字面量包括:1.文本字符串 2.八种基本类型的值 3.被声明为final的常量等。

示例2:

String a = "123";
String b = a + "aaa";
String c = "123aaa";
String d = "123" + "aaa";
System.out.println(b == c);    //false
System.out.println(c == d);    //true

结果你答对了吗?

b 和 d都是对字符串的拼接, 但不同的是 d 的结果在编译期就可以确定, 直接在String Pool开辟了空间; 而 b 中因为掺杂了 a 这个变量, 底层会通过StringBuilder来进行字符串的拼接, 再调用toString()方法转换为字符串, 最终以创建对象的方式在堆区(非String Pool)开辟了空间。所以 b 最终指向堆内存(非String Pool)的某个区域, c 和 d的结果一致, 都指向String Pool中的某个位置。

编译期优化: String d = "123" + "aaa";编译后优化为String d = “123aaa”;`

String b = a + "aaa";的底层处理: new一个StringBuilder, 调用append()添加"aaa", 最后调用StringBuilder#toString赋值给b。需要额外说明的是,调用StringBuilder#toString()方法不会在String Pool中额外开辟内存空间

对于包含变量的字符串拼接, 底层都是调用StringBuider的append()来进行拼接的。拼接方式举例如下:

String a = "123";
String b = a + "aaa";  //拼接方式1
String c = new String("123") + "aaa";  //拼接方式2
String d = new String("123") + new String("aaa"); 拼接方式3

三 String intern()方法的使用

String#intern(), 源码中是如此描述的, 当这个方法被调用时, 如果字符串常量池中已经存在了一个字符串通过equals()方法比较后相等, 则直接返回pool中的这个字符串, 否则将调用该方法的字符串加入到pool中并返回其引用。

intern()方法的目的就是确保字符串在内存中只有一份拷贝, 这样可以节约内存空间,加快字符串操作过程中的执行效率。

为什么要单独再说这一块儿呢? 因为目前在面试过程中对intern()方法的考察越来越多, 面试官为了多方位考察候选人的技术深度和广度会扒一些难点来和候选人进行更加深入的routi交流。下面总结一下intern()方法的G点, 咱们围绕这个点进行深入的沟通与交流。

  1. intern()方法的特点考察

    对于一个字符串, String Pool中没有, 那么调用后就会有, 如果String Pool中有就会直接返回其引用。

  2. intern()方法在JDK6和7的版本变化导致的影响

    在JDK6中, 当一个字符串调用intern()方法时,

    ​ 如果String Pool中不存在, 拷贝一份到String Pool中;

    ​ 如果String Pool中存在, 直接返回引用地址。

    在JDK7中, 当一个字符串调用intern()方法时,

    ​ 如果String Pool中不存在, 将对象的引用地址存储到String Pool中;

    ​ 如果String Pool中存在, 直接返回引用地址。

下面详细来说明针对以上两个特点经常会出的一个面试题:

示例1(引用自深入理解Java虚拟机第三版):

String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);

这段代码在JDK 6中运行,会得到两个false,而在JDK 7中运行,会得到一个true和一个false。

产生差异的原因是,在JDK 6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池 中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在 Java堆上,所以必然不可能是同一个引用,结果将返回false。

而JDK 7(以及部分其他虚拟机,例如JRockit)的intern()方法实现就不需要再拷贝字符串的实例 到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引 用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。

​ – 引用自深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明

此外需要格外注意的是java这个字符串早早就已经在String Pool中存在了, 在虚拟机启动时加载sun.misc.Version这个类就将java这个字符串放进String Pool中了。

package sun.misc;
import java.io.PrintStream;
public class Version {
    
    
    private static final String launcher_name = "java";
    ..........

如有伙伴想了解java字符串详细的加载过程, 可以参考知乎的这个回答。

https://www.zhihu.com/question/51102308/answer/124441115

四 String的底层变化

String的底层实现从char[]切换到byte[]

在JDK9中, 一个重要的变化就是String的底层实现从char[]改为byte[], 这样做的目的主要是为了节约内存。同时显而易见的, 也减少了GC次数。

在大多数Java应用中, String占据的空间是最大的, 而且大多数使用的只有Latin-1字符(对于一些英文字母及数字), 这些字符只需要一个字节来存储, 但是在JDK9之前, JVM使用的是char[]来进行存储, 而一个char字符占据的是两个字节, 这样就导致了一半的空间被浪费掉了。

JDK9及之后的String支持两种编码, Latin-1和UTF-16, 当String的字符使用Latin-1存储不够, 这个时候就会采用UTF-16编码来进行存储。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence,
               Constable, ConstantDesc {
    
    

    @Stable
    private final byte[] value;

    /**
     * The identifier of the encoding used to encode the bytes in
     * {@code value}. The supported values in this implementation are
     *
     * LATIN1
     * UTF16
     *
     * @implNote This field is trusted by the VM, and is a subject to
     * constant folding if String instance is constant. Overwriting this
     * field after construction will cause problems.
     */
    private final byte coder;
    @Native static final byte LATIN1 = 0;
    @Native static final byte UTF16  = 1;

为了标识该字符串的编码类型, 同时引入了coder来表明该字符串是使用的Latin-1还是UTF-16来进行编码。

String在G1收集器上的去重优化

G1收集器现在在一些大厂应用的已经很多了, 但是目前主流的使用依然是JDK8, 直至JDK9中G1收集器才开始作为默认的垃圾收集器, 所以在将String去重放到这一部分来总结一下。

在面试的时候对GC和JVM有一定了解再说出该部分是一个很好的加分项哦!

前面说过Java在大多数应用中String在内存中的占比是相当大的, 很多场景下, 内存也是限制应用的主要的性能瓶颈。而String在堆中发生重复占据内存空间是不必要的并且是可被优化的。在G1收集器中, 通过调用String#equals()方法进行比较增加了对字符串持续的判重去重操作。

具体实现:

当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象。

  1. 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。
  2. 使用一个Hashtable来记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个Hashtable,来看堆上是否已经存在一个一模一样的char数组。
  3. 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
  4. 如果查找失败,char数组会被插入到Hashtable,这样以后的时候就可以共享这个数组了。

命令行选项

  1. UseStringDeduplication(bool) :开启String去重,默认是不开启的,需要手动开启。
  2. PrintStringDeduplicationStatistics(bool) :打印详细的去重统计信息
  3. stringDeduplicationAgeThreshold(uintx) :达到这个年龄的String对象被认为是去重的候选对象

参考文章或书籍:

深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明

[深入解析String#intern]

https://www.zhihu.com/question/447224628/answer/1759986003

猜你喜欢

转载自blog.csdn.net/s78365126/article/details/115474168