深入理解String类 --- 这次终于有人把String将明白啦!

1.引言

        今天我们尝试深入分析一下JAVA中String类的深入点,并不是带大家从零学这个指示点,只是将重难点拿出来研究一下。

2.不可变字符串

        由于JAVA中的字符串类是不允许取修改里面的字符的,所以可以得出这是一个不可变字符串。JAVA文档中将String称为是一个immutable(不可变的)。

        JAVA设计者虽然将字符串设计为不可变的,但是也在底层进行了优化,那就是JAVA的字符串是可用进行共享的。

2.1JAVA字符串存储内存结构分析

        我们先进行分析以下JAVA的内存分区:分为栈,堆,方法区,代码缓存,直接内存五大部分

        栈:用于进行存储局部变量(内存地址)和方法调用时的执行上下文。

        堆:存储对象实例和数组。

        方法区:在JVM的规范中,方法去是所有线程共享的内存区域,用于进行存储已被虚拟机加载的类信息,常量,静态变量等数据。在HotSpot虚拟机中,方法区又是也被称为"永久代"(PermGen),但是在JAVA8之后,永久代被元空间(Metaspace)替代。

        代码缓存:用于存储JIT编译器编译后的本地机器代码。

        直接内存:通过NIO方式申请的内存。

        其中堆内存会存储新建的对象实例(其中也会有字符串对象的实例),字符串常量池是方法区的一部分,进行存储字符串信息,用于进行优化性能,也保证了字符串的不可变性。

        字符串会进行存储到堆内存或者字符串常量池中。

2.2不同创建字符串方式存储行为的不同

2.2.1使用""的方式进行声明

String newString = "牛马";
String newStringB = "牛马";

        使用者这种方式进行创建字符串对象的时候,创建的数据会存储在字符串常量池中,再次使用""声明字符串的时候都会指向字符串常量池中已经存在的字符串,当某个变量的字符串发生改变的时候,又会去创建新的字符串对象去指向新的地址,所以说字符串是不可变的。

2.2.2使用new构造器进行创建字符串对象

        使用new构造器进行创建字符串对象的时候,会在堆内存中进行创建一份新的数据对象,变量指向堆内存中创建出来的对象。

char[] chs = {'哈', '哈', '哈'};
String s1 = new String(chs);
String s2 = new String(chs);

2.2.3使用new String("哈哈哈")的分析

        使用这种方式进行创建字符串的时候,会创建出来两个对象,原因是”哈哈哈“会创建一个对象存放在字符串常量池中,new String又会创建出来一个对象存放到堆内存中。

String newString = new String("哈哈哈");

2.2.4使用变量相加的形式创建出来的字符串也会新建一个字符串对象

String s1 = "哈哈哈";
String s2 = "哈哈";
String s3 = s2 + "哈";

2.2.5字符串相加的编译优化机制

        以下两个变量的指向是一样的,因为JAVA编译优化机制,将”a" + "b" + "c"优化为了"abc"

2.2.6使用StringAPI/+创建的字符串存储位置的总结

        如果我们使用StringAPI/+创建的字符串,无论是进行操作的是变量存储的字符串,还是进行操作的是字面量,得到的新字符串是不会子字符串常量池中进行创建一个新对象的,但是会在堆内存中进行创建一个字符串对象。

        编译器进行优化的时候,仅仅会优化 "a" + "b"这种情况下得到的字符串存储在字符串常量池中,其他情况下进行使用StringAPI无论是不是两个字符串字面量进行操作还是变量进行操作,存储的位置都是在堆内存中。

3.Unicode和char类型

3.1char类型

        char类型是进行表示一个Unicode字符使用的数据类型,char类型可以被表示为十六进制,需要使用\u转义,由于char类型是占用两个字节的,所以char类型进行表示的范围是\u0000到\uFFFF,这样表示的方法也被叫做Unicode转义序列。JAVA中Unicode的编码机制使用的是UTF-16,UTF-16需要使用2个或者4个字节,所以char类型并不能进行表示所有的Unicode字符,素以有时候需要进行表示Unicode字符的时候需要使用两个char类型进行表示。

        需要注意的是:Unicode转义字符在解析代码之前就会得到处理,例如:"\u0022"+"\u0022"得到的并不是"+",而是在代码执行前,\u0022就被解析为“了,最后得到的是”“,一个空字符串。

3.2Unicode和char类型

3.2.1Unicode的编码机制

        Unicode被提出是未来进行解决世界各国间编码机制不统一的问题,是为了进行指定一种规范而诞生的,在上世纪80年代这项工程就启动了,JAVA当时进行设计的时候,由于当时并没有想过世界上会有这么多字符,所以当时仅仅设计由两个字节进行表示Unicode字符,因为当时字符也就占了65536的一半而已。但是后来由于汉语,韩语等大量表意字符的加入,两个字节已经明显不够用了,所以JAVA5.0的时候就开始着手解决这个问题了,码点指的是每个字符所占的代码值,Unicode编码规范中,采用十六进制进行表示码点,并且会加上前缀U+进行表示。Unicode中由十七个代码平面,其中第一个代码平面(被称为基本多语言平面)包含了Unicode中的经典字符,表示范围是U+0000到U+FFFF,其余十六个平面的码点表示了U+10000到U+10FFF,其中包括了辅助字符。

        UTF-16编码采用了不同长度的编码来进行表示Unicode码点,常用的字符用16位进行表示,这里的16位一般被称为一个代码单元,辅助字符使用两个代码单元进行表示。

        其中辅助字符进行表示的时候,两个代码单元使用基本多语言平面中的未被使用的2048个值,第一个代码单元范围在U+D800~U+DBFF,第二个代码单元范围在U+DC00~U+DFFF中。这就很容易通过第一个代码单元判断出来,这到底时一个常用字符还是一个辅助字符。

3.2.2Unicode和char的关系

        这样就很容易得出,char类型进行标识Unicode字符的时候,常见的字符使用一个char,负值字符使用两个char。

        在后续我们会进行详细介绍码点和代码单元,这两个也称为字符串进行操作时需要进行理解的重点和难点。

4.检测字符串是否相等

4.1equals和==

        equals进行检测的是字符串内容是否相同,==是检测两个存储字符串变量的地址是否相同,所以一般进行检测字符串的内容是否相等就应该使用equals。

        如果要忽略字符串中字母的大小写进行比较,就使用equalsIgnoreCase即可。

        有些人可能会以为两个相同内容的字符串,存储的位置都在字符串常量的同位置中进行存储,可以进行使用==,但是这是大错特错的,很有可能两个内容完全相等的字符串,一个存储在字符串常量池中,一个存储在堆内存中。

        所以进行操作的时候,一定不要进行使用 == 进行判断两个字符串的内容是否相同。

4.2compareTo

        compareTo也是一种进行判断字符串是否内容相同的手段,其底层机制依赖的是比较字符字典顺序。

4.2.1字典顺序比较机制

        字典顺序的比较其实就是进行比较Unicode编码对应码点的大小,也就是字符对应的十六进制编码的大小。

4.2.2如果比较每个字符的时候,字符为辅助字符呢?

        辅助字符是由一对代码单元组成的,在这里我们把这对代码单元称为代理对,第一个代理被称作高代理,第二个代理被唱作低代理,进行字典顺序比较的时候,JAVA会将代理对视为一个单一的码点,通过一定的机制进行转换为一个组合码点继续进行比较。

        组合码点的计算公式是:组合码点 = 0x10000+((高代理−0xD800)×0x400)+(低代理−0xDC00)。

        喜欢刨析原理的同学可以进行去研究一下,这里就不进行解析概述了,因为本文的重点不在于此。

4.2.3compare的比较机制

        我们从源码的角度去刨析这个问题。

public int compareTo(String anotherString) {
    int len1 = value.length;
    int len2 = anotherString.value.length;
    int lim = Math.min(len1, len2);
    char v1[] = value;
    char v2[] = anotherString.value;

    int k = 0;
    while (k < lim) {
        char c1 = v1[k];
        char c2 = v2[k];
        if (c1 != c2) {
            return c1 - c2;
        }
        k++;
    }
    return len1 - len2;
}

        compareTo返回的是一个Int类型的整数,标识当前字符串和参数字符串的比较结果。

        首先会先去获取到字符串的长度(我们后边会进行讲解这个比较长度的方法的劣势,不过在这里没有什么影响,这样比较是完全可以的)。获取到长度较短的一个,进行比较的循环也会只把短的代码序列进行比较完。

        进行比较的时候,会依次获取到两个字符串的每个字符,但是要特别注意,进行使用索引获取String中的每个字符的时候,String类内部实现了确保代码对被是为一个整体,所以进行取出的时候,不会因为辅助字符占两个字节的位置,就只能把辅助字符的一部分取出来,在String类底层,早就已经进行处理过这个问题了。

        String内部是如何进行处理的呢?将一个代码对再使用索引取出的时候转换为一个码点,是因为String类的value数组虽然char组成(JAVA8之前,JAVA9之后就使用byte数组了),但是它们在内部是按照UTF-16编码组织的,代理在数组中是进行连续存储的。

        如果进行比较的时候,发现两者字符的字典顺序不相等,如果原字符串大就返回正数,小就返回负数,并终止比较。

        如果长字符串前部分和短字符串相等,那就返回长字符串长度 - 短字符串长度的值(正数)。

        如果长度内容都相等,那也是返回字符串相减,也就是返回0

        所以如果compareTo的返回值是0,就代表两个字符串内容相等,非0就不相同。

        这里也得到一个小知识,使用索引去取字符串的字符的时候,辅助字符也可以一次性取出来的。

5.字符串的长度 --- 码点和代码单元

5.1为什么使用length可能会测不准字符串长度?

        JAVA8中字符串的底层是用char数组实现的(JAVA9及以后就改成byte数组实现了),char是UTF-16编码标识Unicode编码的代码单元,字符串的length方法获取的是字符串中代码单元的长度,但是辅助字符占用可是两个代码单元啊,有个码友可能会说,length方法会不和字符串索引一样对辅助字符进行编码为一个代理对象?很抱歉不回,因为length本身就是用来进行设计为获取代码单元长度的,所以,如果字符串中有辅助字符,就会出现测不准长度的问题(很多emoji文字可都是辅助字符啊)。

5.2怎么样才能测准字符串的长度?

        当然就是进行测量码点的长度,码点是字符对应的代码编码,所以测出来字符串的码点长度就可以获取到字符串的真实长度了(即使有辅助字符)。

        以下代码可以准确获取到码点的长度,也就是可以准确获取到字符串的长度。

String s1 = "牛马";
int count = s1.codePointCount(0, s1.length);

        如果想要准确获取到第i个码点可以使用以下代码(其实直接使用索引也可以)。

String s1 = "牛马";
int index = s1.offsetByCodePoints(i);
int cp = s1.codePointAt(index);

        遍历字符串也很简单,直接进行获取到码点数量,然后使用索引循环访问即可,这里不再给出代码演示。

5.3substring使用的时候需要注意什么?

        String substring(int beginIndex, int endIndex)

        其中两个索引都是代码单元的索引位置,所以使用substring切割字符串的时候,如果字符串中的双代码单元符,就需要格外注意,一不小心,就可能把双代码单元字符给一分为二了。

        所以最好进行遵循以下步骤

        1.确定代理对:首先需要进行识别字符串中的代理对,在JAVA中,代理由一个高代理和一个低代理组成(前面我们讲到过)

        2.使用codePointAt和codePointBefore来进行确定字符串的其实和结束位置,返回的都是字符码点,不是代码单元。

        3.计算正确的代码单元索引:使用offsetByCodePoints方法基于码点进行计算索引。

        4.使用subSequence:使用subSeqence方法来获取子字符串,subSequence接收的索引都是码点偏移量,而不是代码单元的索引,可以进行配合offsetByCodePoints.

String str = "Hello\uD800\uDC00 World!";
int start = 5; // "Hello" 的长度
int end = 14; // 包括 "Hello" 和一个双代码单元字符

// 计算正确的起始和结束索引
int codePointStart = str.offsetByCodePoints(start, 1);
int codePointEnd = str.offsetByCodePoints(end, -1);

// 使用 subSequence 获取子字符串
String subStr = str.subSequence(codePointStart, codePointEnd).toString();

System.out.println(subStr); // 输出 "Hello𐀀 World"

6.结语

        这篇文章也是准备了很久,如果你喜欢可以留下你得点赞收藏关注,我将会持续创作的!

  • 11
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值