Java技术进阶 + 关注 云栖社区

Java总结 - String -> 这篇请使劲喷我

  1. 云栖社区>
  2. Java技术进阶>
  3. 博客>
  4. 正文

Java总结 - String -> 这篇请使劲喷我

期待l 发布时间:2019-01-10 22:18:26 浏览446 评论0

摘要: 首先我要提前说明的一点是,这篇文章是我自己的理解,而且其中涉及了一些JVM指令,但是自己没有学过这些东西,完全是靠自己的感觉在写,所以我感觉本片文章会有些漏洞,因此您只可以做一个参考,我希望您发现不对的地方即使指正,非常感谢 这篇是考虑再三冒死拿出来给大家看的,因为一直放在我的笔记对错我自己完全...

  • 首先我要提前说明的一点是,这篇文章是我自己的理解,而且其中涉及了一些JVM指令,但是自己没有学过这些东西,完全是靠自己的感觉在写,所以我感觉本片文章会有些漏洞,因此您只可以做一个参考,我希望您发现不对的地方即使指正,非常感谢
  • 这篇是考虑再三冒死拿出来给大家看的,因为一直放在我的笔记对错我自己完全不知道,所以孬活着不如快乐一死,接收喷,但请带上您的理由,嘻嘻

String继承关系

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence{
      ....
    }
  • 到这我们可以看到String类不可以被继承,因为final修饰,所以他的方法自然不可以被重写,然后String可以进行序列化,比较,以及他实现了CharSequence字符序列接口
  • 总结:String可序列化,可比较,emmm...是个字符序列

String的存储实现

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence{
      //之前版本的JDK的String实现是char数组,需要两个字节
      //而在之后更新的JDK中实现为byte数组,加上一个下面的coder标志来表示字符
      private final byte[] value;   
      //用于对value的byte进行编码的编码标识符,即使用什么编码对value进行编码
      //支持的编码有LATIN1即ISO-8859-1单字节编码和UTF-16双字节编码
      //如果value中保存的字符串都可以用LATIN1保存,那么coder=0,否则就使用UTF-16保存,coder=1
      private final byte coder;
      //缓存的hash值,默认为0
      private int hash;
      //数字代表编码
      @Native static final byte LATIN1 = 0;
      @Native static final byte UTF16  = 1;
    }
  • String在JDK8中存储的形式还是private final char value[]的,这个变化在JDK9中发生改变的,这个改变使字符串能够占用更少的空间,因为原来实现的数组是char,是2字节长度,那么在更改为byte数组后,每个元素只有一个字节的长度,所以节省了一半的空间(并不准确,但是肯定比之前的char节省,下面介绍)
  • 比如之前存储how单词,char[]数组是这样的

    [0][h][0][o][0][w]
    //之后byte单字节存储
    [h][o][w]
  • 如下图是JDK8和JDK11中分别存储how后的char[]数组内的情况

markdown_img_paste_20190110120040637

markdown_img_paste_20190110120102427

  • 所以从上面看到,在存储字母的时候,也就是单字节可以存放一个字母的时候,存储效率达到了最高,这时候就真的是之前char[]存储的一半了,但是中国汉字不止占一个字节,即一个byte存不下一个汉字了,这时候还是需要2字节去存储的,比如JDK8和JDK11分别存储期待a,如下图

markdown_img_paste_20190110120251236

markdown_img_paste_20190110120417963

  • 如上图,由于单字节存不下汉字,所以coder编码标识符改为了1,即UTF-16
  • 对于coder可以这样做一个实验,如下

    String str = new String("xx");   //当你在构造器打断点的时候,此时coder=0
    String str = new String("中国"); //coder=1
  • 所以从Java9 的 String 默认是使用了上述紧凑的空间布局的
  • 这一改变,也直接影响了String.length()方法

    public int length() {
      //即如果是 LATIN-1 编码,则右移0位.数组长度即为字符串长度.而如果是 UTF16 编码,则右移1位,数组长度的二分之一为字符串长度
        return value.length >> coder();
    }
    byte coder() {
      //COMPACT_STRINGS默认为true
      //这个变量就代表了String一开始是否使用紧凑布局,这个参数由JVM注入,只能通过虚拟机参数更改
      //意思就是如果是紧凑布局的话,那么我们就使用coder作为返回值,coder会根据你存的string的内容变化
      //如果是False就是放弃紧凑布局,那么就是用双字节进行存储内容
        return COMPACT_STRINGS ? coder : UTF16;
    }
  • 既然String子层存储发生了变化,那么相关的StringBuilderStringBuffer也发生了变化,如下是他们两个类的父类

    abstract class AbstractStringBuilder implements Appendable, CharSequence {
        byte[] value;
        byte coder;
      }
  • 总结:String在JDK9之前使用char[]保存数据,在JDK9开始使用byte[]保存数据,并有一个coder标志符,来表示数据是用哪一种编码保存的,以方便之后的方法进行区分对待,并且相关的String类都发生了改变

String初始化过程

  • new String(char[] ch)构造器开始

    //断点代码
    public static void main(String[] args) {
        char[] chars = {'A', 'B'};
        String str = new String(chars);
        System.out.println(str);
    }
    //String构造器
    public String(char value[]) {
        this(value, 0, value.length, null);
    }
    //String包级别构造器
    String(char[] value, int off, int len, Void sig) {
      //这的注释可以过一眼,等你看完下面的流程后,你就知道这是什么作用了
        if (len == 0) {
            this.value = "".value;
            this.coder = "".coder;
            return;
        }
        //COMPACT_STRINGS默认为true,即代表启用压缩,即使用单字节编码
        if (COMPACT_STRINGS) {
          //compress里面判断如果char数组存在 value > 0xFF 的值时,就返回null, 0xFF=255
          //如果内容全部小于0xFF,即代表可以全部采用单字节编码
          //那么返回值就不是null,那么直接赋值给String类中属性就行了
            byte[] val = StringUTF16.compress(value, off, len);
            if (val != null) {
              //直接赋值给value属性,单字节编码初始化完毕
                this.value = val;
                this.coder = LATIN1;
                return;
            }
        }
        //到这就代表上面遇到了不能直接单字节编码的String了,然后就开始采用双字节编码
        this.coder = UTF16;
        //然后将要保存的String用UTF16编码即可,到这就初始化完毕
        this.value = StringUTF16.toBytes(value, off, len);
    }
    public static byte[] compress(char[] val, int off, int len) {
      //这个就是存放char转到byte后的数据的临时数组
        byte[] ret = new byte[len];
        //内部调用,里面判断是否c>0xFF,如果都小于,就代表可以全部单字节编码,返回值就==len
        //如果遇到了c>0xFF的情况,那么这个条件不会成立
        if (compress(val, off, ret, 0, len) == len) {
          //到这就代表已经保存进了byte数组内了,返回就可以
            return ret;
        }
        //这就代表需要保存的String不能直接单字节编码
        return null;
    }
    public static int compress(char[] src, int srcOff, byte[] dst, int dstOff, int len) {
        for (int i = 0; i < len; i++) {
            char c = src[srcOff];
            //判断char中的每个是否value > 0xFF
            if (c > 0xFF) {
              //如果发现c>0xFF那么len赋值为0,跳出,所以len返回值也为0,所以会造成上层判断为false
                len = 0;
                break;
            }
            //如果不遇到break,说明要保存的String,可以直接用单字节编码,循环完成了,即char也保存到了byte了
            dst[dstOff] = (byte)c;
            //指针++
            srcOff++;
            dstOff++;
        }
        return len;
    }
  • JDK8中的初始化

    public String(char value[]) {
        this.value = Arrays.copyOf(value, value.length);
    }
  • 到这一个String的基本的构造过程就写完了,这一部分掰扯了好半天,全是自己的理解,如果有分析的不对,请及时指正
  • 总结:已经抛弃了JDK8中的系统拷贝(2字节),转而使用字符编码来区别初始化(1字节 or 2字节)

String中的常用方法实现

  • 使用方法就不多说了,来看一下他们的实现:substring,,replace

    //截取字符串
    public String substring(int beginIndex, int endIndex) {
         int length = length();
         //检查是否越界
         checkBoundsBeginEnd(beginIndex, endIndex, length);
         //截取的长度
         int subLen = endIndex - beginIndex;
         if (beginIndex == 0 && endIndex == length) {
             return this;
         }
         //根据编码来区分截取字符,注意他们的方法是!!newString!!,所以不用跟进去也知道他是创建一个新的子串
         return isLatin1() ? StringLatin1.newString(value, beginIndex, subLen)
                           : StringUTF16.newString(value, beginIndex, subLen);
     }
    
    //替换字符串
    public String replace(char oldChar, char newChar) {
        if (oldChar != newChar) {
          //StringLatin1这里面的方法有点长,如下
            String ret = isLatin1() ? StringLatin1.replace(value, oldChar, newChar)
                                    : StringUTF16.replace(value, oldChar, newChar);
            if (ret != null) {
                return ret;
            }
        }
        return this;
    }
    //因为return可以直接返回结果,所以我们直接看返回就好了,下面是精简后的程序,详细程序我看不懂..嘻嘻
    //看到都是newString返回的
    public static String replace(byte[] value, char oldChar, char newChar) {
        if (canEncode(oldChar)) {
            ...
            if (i < len) {
                ...
                    return new String(buf, LATIN1);
                } else {
                   ...
                    return new String(buf, UTF16);
                }
            }
        }
        return null; // for string to return this;
    }
  • 所以到这我们可以看到返回的是一个新的子串,而并非对原来的String做任何的改变,这也可以作为String是immutable的证据
  • 总结:String的任何操作都是返回一个新的子串,而并非对原来的String做任何修改,String对象一旦被创建就是固定不变的了,对String对象的任何改变都不影响到原对象,相关的任何change操作都会生成新的对象

常量池

  • 这部分内容我不知道怎么去验证,所以是参考网上的帖子,文末有引用说明
  • 字符串在我们编程中使用是很多的,所以如果不引入一个机制,那么除了不重复的字符串缓存外,重复的部分那么将是无用的,所以这个机制就是常量池,在java中常量池可以保证池中一个字符串仅且只有这一个,不会出现第二个备份,所以这也就很好的解决了重复字符串的问题
  • 当我们创建一个字符串的时候,Java会先去线程池中寻找,如果有就返回这个字符串,否则就新建一个放入池中,当然这个操作是排除new String()操作的,仅支持直接可以能够判断出变量值的状态,比如下面

    //可以直接得到变量的值
    String str1 = "1";
    String str2 = "1";
    System.out.println(str1 == str2);  //true
    //下面是不能直接得到值的,比如new String()
    String str1 = "1";
    String str2 = new String("1");
    System.out.println(str1 == str2); //false
  • 对于下面这种情况,刚开始还不理解为啥是true,感觉这个只能运行期才能获取值啊,应该是false啊,但是却不是,后来想了一下,因为getNum方法中是return "1";也相当于在创建对象,需要到池中搜索,结果发现有1,所以比较会返回true,这只是我的猜测,我还不知道怎么去证实,如果不对请指正,谢谢
  • 补充:虽然getNum比较返回为true,但是只是证明是常量池中的一个对象,而这个方法依旧是运行时才会知道其返回值的,即编译器无法确定他的具体值

    public void test() {
        String str1 = "1";
        String str2 = getNum();
        System.out.println("1" == str1);  //true
        System.out.println("1" == str2);  //true
        System.out.println(str1 == str2);  //true
    }
    private String getNum(){
        return "1";  //我的证实是将这里改为new String("1"),上面会返回false,所以得出上面的结论
    }
  • 到这就需要提到两个概念:静态常量池,动态常量池

    • 静态常量池.即*.class文件中的常量池,class文件中的常量池不仅仅包含字符串(数字)字面量,还包含类,方法的信息,占用class文件绝大部分空间
    • 运行时常量池,则是jvm虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池
  • 提到上面两个概念,可以解决这个问题:上面说创建字符串时会在常量池中寻找,那么new String("1")为啥不等于String str = "1"呢?

    • new操作其实是创建了一个真正的对象,这个我们都知道,所以这个new出来的对象1一定会在堆内存,我们之前也证实了不管从常用方法还是常量池机制都保证了不会有重复的字符串,所以这的唯一可能就是new出来的对象是引用常量池中的对象的,如果常量池中没有这个对象,new操作就会先在常量池中新建一个常量,然后再引用他,如下图

markdown_img_paste_20190110201235483

  • 对应如下代码段
String str = new String("1");
String n = "1";
System.out.println(str == n);  //false
  • 到这就可以看出了,比较str = n,其一次指向完全不同,所以返回false
  • 那怎么证明new String真的是创建了一个对象一个常量呢 ?(一个new 对象在堆,一个在常量池),我们使用到了javap -verbose 输出附加信息

    • 首先如下空实现
    public static void main(String[] args) {}
    • javap一下,只截取有用的部分
    public class com.qidai.Tests
    //...
    Constant pool:    //常量池出现,其中没有我们定义的字符,因为是空实现哈哈
       #1 = Methodref          #3.#17         // java/lang/Object."<init>":()V
       #2 = Class              #18            // com/qidai/Tests
       #3 = Class              #19            // java/lang/Object
       #4 = Utf8               <init>
       #5 = Utf8               ()V
       #6 = Utf8               Code
       #7 = Utf8               LineNumberTable
       #8 = Utf8               LocalVariableTable
       #9 = Utf8               this
      #10 = Utf8               Lcom/qidai/Tests;
      #11 = Utf8               main
      #12 = Utf8               ([Ljava/lang/String;)V
      #13 = Utf8               args
      #14 = Utf8               [Ljava/lang/String;
      #15 = Utf8               SourceFile
      #16 = Utf8               Tests.java
      #17 = NameAndType        #4:#5          // "<init>":()V
      #18 = Utf8               com/qidai/Tests
      #19 = Utf8               java/lang/Object
    {
      public com.qidai.Tests();
      //...
      public static void main(java.lang.String[]);  //main方法开始
        Code:
          stack=0, locals=1, args_size=1
             0: return   //无实现直接返回
    }
    • 然后我们在main中加入代码
    String string = new String("MyConstantString");
    • 编译一下再javap看一下
    public class com.qidai.Tests
    Constant pool:
       #1 = Methodref          #6.#22         // java/lang/Object."<init>":()V
       #2 = Class              #23            // java/lang/String
       #3 = String             #24            // MyConstantString
       #4 = Methodref          #2.#25         // java/lang/String."<init>":(Ljava/lang/String;)V
       #5 = Class              #26            // com/qidai/Tests
       #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               Lcom/qidai/Tests;
      #14 = Utf8               main
      #15 = Utf8               ([Ljava/lang/String;)V
      #16 = Utf8               args
      #17 = Utf8               [Ljava/lang/String;
      #18 = Utf8               string
      #19 = Utf8               Ljava/lang/String;
      #20 = Utf8               SourceFile
      #21 = Utf8               Tests.java
      #22 = NameAndType        #7:#8          // "<init>":()V
      #23 = Utf8               java/lang/String
      #24 = Utf8               MyConstantString      //!!!!!!!!!!!类文件中出现了~~~~~
      #25 = NameAndType        #7:#28         // "<init>":(Ljava/lang/String;)V
      #26 = Utf8               com/qidai/Tests
      #27 = Utf8               java/lang/Object
      #28 = Utf8               (Ljava/lang/String;)V
    {
      public static void main(java.lang.String[]);
        Code:
          stack=3, locals=2, args_size=1  //因为有实现了,所以没有直接返回
             0: new           #2                  // class java/lang/String
             3: dup
             4: ldc           #3                  // String MyConstantString !!!!!!!!!!!!
             6: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
             9: astore_1
            10: return
    }
    • 果然证明了我们说的问题,就是new String()的时候会创建一个对象和一个常量,至此我们就算验证结束了,但是我们还可以验证一个其他的问题:不是说常量池不重复的嘛,那么我们再定义一个一样数据的String呢?,所以我们现在main方法中就有两行内容了,如下
    String string = new String("MyConstantString");
    String constant = "MyConstantString";
    • 编译此类然后javap查看
    public class com.qidai.Tests
    Constant pool:
       #1 = Methodref          #6.#23         // java/lang/Object."<init>":()V
       #2 = Class              #24            // java/lang/String
       #3 = String             #25            // MyConstantString
       #4 = Methodref          #2.#26         // java/lang/String."<init>":(Ljava/lang/String;)V
       #5 = Class              #27            // com/qidai/Tests
       #6 = Class              #28            // java/lang/Object
       #7 = Utf8               <init>
       #8 = Utf8               ()V
       #9 = Utf8               Code
      #10 = Utf8               LineNumberTable
      #11 = Utf8               LocalVariableTable
      #12 = Utf8               this
      #13 = Utf8               Lcom/qidai/Tests;
      #14 = Utf8               main
      #15 = Utf8               ([Ljava/lang/String;)V
      #16 = Utf8               args
      #17 = Utf8               [Ljava/lang/String;
      #18 = Utf8               string       //常量池中会加入Strig引用变量??
      #19 = Utf8               Ljava/lang/String;
      #20 = Utf8               constant     //常量池中会加入Strig引用变量??
      #21 = Utf8               SourceFile
      #22 = Utf8               Tests.java
      #23 = NameAndType        #7:#8          // "<init>":()V
      #24 = Utf8               java/lang/String
      #25 = Utf8               MyConstantString  //仅有一个~~~
      #26 = NameAndType        #7:#29         // "<init>":(Ljava/lang/String;)V
      #27 = Utf8               com/qidai/Tests
      #28 = Utf8               java/lang/Object
      #29 = Utf8               (Ljava/lang/String;)V
    {
      public com.qidai.Tests();
      public static void main(java.lang.String[]);
        Code:
          stack=3, locals=3, args_size=1
             0: new           #2                  // class java/lang/String
             3: dup
             4: ldc           #3                  // String MyConstantString
             6: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V 初始化方法?
             9: astore_1
            10: ldc           #3                  // String MyConstantString  这应该就是咱们定义的constant了
            12: astore_2
            13: return
    }
    • 因为自己并没有看过深入理解Java虚拟机所以对上面也是一知半解,我们用javap证明了他确实会只保存一个常量,但是我们看到常量池中会加入Strig引用变量??这个问题我还不知道怎么回答,如果您知道此问题,请评论告诉我,谢谢,暂且记住常量池中会出现引用变量吧,毕竟他是真实存在的,但我们现在分析的只是class类文件,而JVM中的动态常量池中应该会把他去掉,但这个全局的常量池中应该会有一个与常量池保存引用的一个机制,要么怎么找到常量池中的对象呢?我感觉这个在class文件中的常量池中出现只是在描述这个类信息,也就是说有点定义的意思,这是自己理解的,别信...我说真的...自己没把握...
  • 好了知道了这些内容,我们还需要知道JVM在编译的时候会进行编译优化的,比如宏变量的替换,比如上面的可确定的变量直接写为确定值了,比如

    public static void main(String[] args) {
        String str = "1"+"2"+"3";
        String method = getNum();
    }
    private static String getNum() {return "1";}
    • 编译查看class文件,IDEA就可以直接点击生成的class文件进行查看
    public static void main(String[] args) {
        String str = "123";  //直接替换为可确定值
        String method = getNum(); //这是不可确定的
    }
    private static String getNum() {return "1";}
  • 好了知道了会进行编译优化的话,我们来看几个实例

    public static void main(String[] args) {
        String s0= "helloworld"; //直接常量池 helloworld
        String s1= new String("helloworld"); //堆helloworld+引用常量池helloworld
        //javap看是hello和一个world常量
        String s2= "hello" + new String("world");
        System.out.println("===========test4============");
        //s0常量引用不等于s1的堆引用
        System.out.println( s0==s1 ); //false
        //s0的常量引用不等于s2的堆引用和常量引用
        System.out.println( s0==s2 ); //false
        //s1不等于s2,因为s2生成了两个一个hello和一个world
        System.out.println( s1==s2 ); //false
    }
    • 如上一切都很正常,唯独s2比较特殊,javap查看constant pool中有三个常量:helloworld,world,hello\u0001,这个\u0001有人知道是什么东西吗??连接符?
  • 早期版本的常量池是放入永生代的,但是永生代是大小有限制的,所以在之后版本中将常量池放入了堆中,避免了永久代沾满的问题,甚至永久代在JDK8中被替换为METASpace元数据区替代了
  • 总结:String的常量池保证只有一个唯一的字符串,不会发生重复,并且newString操作是先去判断常量池中是否有常量,有则引用,否则创建并且JVM会自动编译优化,将可以直接确定下来的值替换掉原来的值

intern

  • 这个是一个可以扩充常量池内常量的个数的方法,即new String的时候,他会去创建一个常量在常量池,本文之前都是这么说的,但是在网上的帖子中说到这个创建常量池的动作是lazy的,所以堆中有了对象而不一定常量池中也会有,即字符串字面量会进入到当前类的运行时常量池,不会进入全局的字符串常量池,所以这个方法其实是触发lazy机制,使其将数据放入常量池,下面有一篇美团的分享贴,可以看一下,自己不太理解就不多比比了
  • intern是显示排重机制,但是每次调用就很麻烦,在jdk8u20推出了G1 GC下的字符串排重,他是通过将相同数据的字符串指向同一份数据来做到的,是JVM底层改变,并不涉及API的修改
  • G1 GC排重默认是关闭的,需要指定

    -XX:+UseStringDeduplication
  • 总结:可以扩充常量池内常量的个数,在Java8特定版本后,JVM就会帮我们做这件事

String,StringBuffer,StringBuilder

  • String是java语言非常基础和重要的类,提供了构造和管理字符串的各种基本逻辑,他是典型的immutable类,被声明成为final class,所有属性也都是final的,由于它的不可变现,类似拼接裁剪动作都会产生新的String对象
  • StringBuffer是解决拼接太多造成很多String对象的类,本质是一个线程安全的可修改字符序列,他保证了线程安全,但同时带来了额外的性能开销
  • StringBuilder是jdk5新增的,和StringBuffer类似,只是这个不是线程安全的
  • String是Immutable类的典型实现,他保证了线程安全,因为无法对内部数据进行更改
  • StringBuffer显示的一些细节,他的线程安全是通过把各种修改数据的方法加上sync实现的,
  • 为了实现修改字符序列的目的,StringBuffer和StringBuilder底层都是利用可修改的数组,二者都集成了AbstractStringBuilder,之间的区别仅仅是方法是否加了sync
  • 内部数组的大小的实现是:构造时初始字符串长度加16,所以可以根据自己的需要创建合适的大小
  • 在java8中字符串的拼接操作会转换为StringBuilder操作,而java9提供了StringConcatFactory,作为统一入口

有价值的参考贴

【云栖快讯】云栖专辑 | 阿里开发者们的第20个感悟:好的工程师为人写代码,而不仅是为编译器  详情请点击

网友评论