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.结语
这篇文章也是准备了很久,如果你喜欢可以留下你得点赞收藏关注,我将会持续创作的!