1 典型回答和考察目的
1.1 String、StringBuffer与StringBuilder的区别
答:(1)String提供了构造和管理字符串的各种基本逻辑,它是典型的不可变类,被声明成为final class,所有属性也都是final的。也由于它的不可变性,类似字符串拼接、裁剪等动作,都会产生新的String对象。由于字符串操作使用非常频繁,所以相关操作的效率往往对应用性能有明显影响;
(2)StringBuffer是为解决上面提到字符串拼接产生太多中间对象的问题而提供的一个类,可以用append或insert方法,把字符串添加到已有序列的末尾或者指定位置。StringBuffer本质是一个线程安全的可修改字符序列,它保证了线程安全;也随之带来了额外的性能开销,所以除非有线程安全的需要,不然还是推荐使用它的后继者,也就是StringBuilder;
(3)StringBuilder是Java 1.5中新增的,在能力上和StringBuffer没有本质区别,区别仅在于最终的方法删除了synchronized,有效减小了开销,是绝大部分情况下进行字符串拼接的首选。
1.2 考察的目的
●是否对字符串编解码有深入了解(中级)
●是否对字符串在内存当中的存储形式有深入了解(高级)
●通过 String 和相关类,考察基本的线程安全设计与实现,各种基础编程实践。
●是否对 JVM 字节码有足够的了解(高级)
●是否对 JVM 指令有一定的认识(高级)
●考察 JVM 对象缓存机制的理解以及如何良好地使用。
●考察 JVM 优化 Java 代码的一些技巧。
●String 相关类的演进,比如 Java 9 中实现的巨大变化。
2 String
2.1 String对象是不可变的,那它不可变是怎么实现的呢?
答:从我们常用的jdk1.8的源码中可以看出,(1)String不可变的第一点是,String类用了final修饰符,当一个类被final修饰时,表明这个类不能被继承,所以String类不能被继承。(2)String不可变的第二点是,用来存储字符串的char value[]数组被private和final修饰,当一个被final修饰的基本数据类型的变量,则其数值一旦在初始化之后便不能更改。
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
// 用于存储字符串的值
private final char value[];
// 缓存字符串的 hash code
private int hash; // Default to 0
// ......其他内容
}
2.1.1 为什么String要用final修饰,能带来哪些好处?
答:第一个好处是安全;第二个好处是高效。从安全角度,当我们在调用字符串时,可能有多线程在访问它,如果是可变类的话,可能在校验过后,它的内部的值又被改变了,这样有可能会引起严重的不可控问题。从高效角度,只有字符串是不可变时,我们才能实现字符串常量池,字符串常量池可以为我们缓存字符串,提高程序的运行效率。如果字符串是可变的,那当 s1 的值修改之后,s2 的值也跟着改变了,这样就和我们预期的结果不相符了,因此也就没有办法实现字符串常量池的功能了。
2.2 ==和equals的区别是什么?
答:== 对于基本数据类型来说,是用于比较 “值”是否相等的;而对于引用类型来说,是用于比较引用地址是否相同的。对于 equals() , Object 中的 equals() 方法其实就是 ==,而 String 重写了 equals() 方法把它修改成比较两个字符串的值是否相等。
源码如下:
// Object
public boolean equals(Object obj) {
return (this == obj);
}
// String
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = length();
if (n == anotherString.length()) {
int i = 0;
while (n-- != 0) {
// 循环比对两个字符串的每一个字符
if (charAt(i) != anotherString.charAt(i))
return false;
i++;
}
return true;
}
}
return false;
}
2.3 String的intern()有什么作用?在JDK6、7和8中有什么区别?
答:String在Java 6以后提供了intern()方法,目的是提示JVM把相应字符串缓存起来,以备重复使用。在我们创建字符串对象并调用intern()方法的时候,如果常量池中已经有缓存的字符串,就会返回缓存里的对象;否则将其缓存到常量池中,并返回对该对象。一般来说,JVM会将所有的类似“abc”这样的文本字符串,或者字符串常量之类缓存起来。
学习链接:深入解析String#intern
2.3.1 一般使用JDK6这种历史版本,并不推荐大量使用intern,为什么呢?
答:(1)在 JDK6 以及以前的版本中,字符串的常量池是放在永久代区( Perm 区)的,永久代区是一个类静态的区域,主要存储一些加载类的信息,常量池,方法片段等内容,默认大小只有4m,,也基本不会被 FullGC 之外的垃圾收集照顾到,一旦常量池中大量使用 intern 是会直接产生java.lang.OutOfMemoryError: PermGen space错误的。
(2)在 JDK7 的版本中,字符串常量池已经从永久代区移到正常的堆区域了。为什么要移动,永久代区太小是一个主要原因。
(3)甚至 永久代在JDK 8中被MetaSpace(元数据区)替代了。而且,默认缓存大小也在不断地扩大中,从最初的1009,到7u40以后被修改为60013。
2.3.2 intern()在JDK6、7中有什么区别?
答:String s3 = new String(“3”) + new String(“3”)和s3.intern()这2步操作中,JDK6常量池是放在永久代中,永久代区和正常的堆区域是完全分开的,所以在常量池中创建一个新"33"对象,因此引用地址是不同的;JDK7后常量池移到了堆上,常量池中不会重新创建对象了,会直接保存堆中(s3引用指向)的对象引用,因此引用地址是相同的。
// 创建了2个对象,分别是 字符串常量池中的对象"1" 和 在堆中(s引用指向)的对象"1"
String s = new String("1");
// 堆中s去常量池中寻找后发现"1"已经在常量池里了,返回常量池中的字符串。注意:s引用指向还是在堆中的对象"1"
s.intern();
// 创建一个s2的引用指向 字符串常量池中的对象"1"
String s2 = "1";
// s 和 s2 地址不同 false
LogUtils.d(TAG, "s == s2:" + (s == s2));
// 创建了2个对象,分别是 字符串常量池中的对象"3" 和 在堆中(s3引用指向)的对象(s3引用对象的内容是"33")。但此时常量池中是没有对象"33"的。
String s3 = new String("3") + new String("3");
// 将s3中的"33"字符串放入String常量池中,因为此时常量池中不存在"33"字符串,
// JDK6常量池是放在永久代中,永久代区和正常的堆区域是完全分开的,所以在常量池中创建一个新"33"对象,因此引用地址是不同的;
// JDK7后常量池移到了堆上,常量池中不会重新创建对象了,会直接保存堆中(s3引用指向)的对象引用,因此引用地址是相同的。
s3.intern();
// JDK7:声明s4的"33"是显示声明的,因此会直接去常量池中创建,创建时发现常量池中已存在"33"对象,s4指向s3引用对象,所以s4引用指向就和s3一样了,因此最后s3==s4是true
String s4 = "33";
// JDK6:s3与s4地址不同 false
// JDK7:s3与s4地址相同 true
LogUtils.d(TAG, "s3 == s4:" + (s3 == s4));
// 创建了2个对象,分别是 字符串常量池中的对象"7" 和 在堆中(s7引用指向)的对象(s7引用对象内容是"77")。但此时常量池中是没有对象"77"的。
String s7 = new String("7") + new String("7");
// 声明s8时,常量池中是不存在对象"77"的,但执行完毕后,"77"对象是s8声明创建的新对象
String s8 = "77";
// 常量池中对象"77"已经存在了,因此s7和s8的引用是不同的
s7.intern();
// s7 和 s8 地址不同 false
LogUtils.d(TAG, "s7 == s8:" + (s7 == s8));
2.4 String在JVM中是如何存储的?编译器对String做了哪些优化?
答:
2.4.1 new String(“3”) 创建了几个对象?
答:String的创建方式有两种,直接赋值和new String() 的方式。直接赋值的方式会先去字符串常量池中查找是否已经有此值,如果有则把引用地址直接指向此值,否则会先在常量池中创建,然后再把引用指向此值;而new String()的方式一定会先在堆上创建一个字符串对象,然后再去常量池中查询此字符串的值是否已经存在,如果不存在会先在常量池中创建此字符串,然后把引用的值指向此字符串。例如:
// 创建了2个对象,分别是 字符串常量池中的对象"1" 和 在堆中(s引用指向)的对象"1"
String s = new String("1");
// 创建一个s2的引用指向 字符串常量池中的对象"1"
String s2 = "1";
// s 和 s2 地址不同 false
LogUtils.d(TAG, "s == s2:" + (s == s2));
// 创建了2个对象,分别是 字符串常量池中的对象"3" 和 在堆中(s3引用指向)的对象(s3引用对象的内容是"33")。但此时常量池中是没有对象"33"的。
String s3 = new String("3") + new String("3");
2.4.2 在没有线程安全问题的情况下,全部拼接操作是应该都用StringBuilder实现吗?使用+进行字符串拼接真的会产生许多无用的对象吗?
答:不一定。在日常编程中,保证程序的可读性、可维护性,往往比所谓的最优性能更重要,我们可以根据实际需求酌情选择具体的编码方式。
String s1 = "Ja" + "va";
String s2 = "Java";
System.out.println(s1 == s2); // 虽然 s1 拼接了多个字符串,但对比的结果却是 true
s2 = "Android";
Long l1 = 1000L;
long l2 = 1000;
System.out.println(s2 + l1 + l2);
(1)我们使用反编译工具,JDK 8的输出片段是:
0: ldc #2 // String Java
2: astore_1
3: ldc #2 // String Java
5: astore_2
从编译代码 #2 可以看出,代码 “Ja”+“va” 被直接编译成了 “Java” ,因此 s1==s2 的结果才是 true,这就是编译器对字符串优化的结果。
51: new #12 // class java/lang/StringBuilder
54: dup
55: invokespecial #13 // Method java/lang/StringBuilder."<init>":()V
58: aload_2
59: invokevirtual #14 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
62: aload 4
64: invokevirtual #15 // Method java/lang/StringBuilder.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
67: lload 5
69: invokevirtual #16 // Method java/lang/StringBuilder.append:(J)Ljava/lang/StringBuilder;
72: invokevirtual #17 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
75: invokevirtual #18 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
从编译代码 #51-#75 可以看出,涉及到变量+,代码 s2 + l1 + l2 被直接编译成了 new StringBuilder().append(s2).append(l1).append(l2).toString(),这也是编译器对字符串优化的结果:非静态的拼接逻辑在 JDK 8 中会自动被 javac 转换为 StringBuilder 操作。
(2)而在 JDK 9 中,反编译的结果就会有点特别了,片段是:
// concat method
1: invokedynamic #2, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
// ...
// 实际是利用了MethodHandle,统一了入口
0: #15 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
JDK 9 利用 InvokeDynamic,将字符串拼接的优化与 javac 生成的字节码解耦,假设未来 JVM 增强相关运行时实现,将不需要依赖 javac 的任何修改。
2.5 String相关类的演进,比如Java 9中实现有哪些巨大变化?
答:(1)在历史版本中,它是使用 char 数组来存数据的,这样非常直接。但是 Java 中的 char 是两个 bytes 大小,拉丁语系语言的字符,根本就不需要太宽的 char,这样无区别的实现就造成了一定的浪费。
(2)在 Java 9 中,我们引入了 Compact Strings 的设计,对字符串进行了大刀阔斧的改进。将数据存储方式从 char 数组,改变为一个 byte 数组加上一个标识编码的所谓 coder,并且将相关字符串操作类都进行了修改。另外,所有相关的 Intrinsic 之类也都进行了重写,以保证没有任何性能损失。
(3)当然,在极端情况下,字符串也出现了一些能力退化,比如最大字符串的大小。你可以思考下,原来 char 数组的实现,字符串的最大长度就是数组本身的长度限制,但是替换成 byte 数组,同样数组长度下,存储能力是退化了一倍的!还好这是存在于理论中的极限,还没有发现现实应用受此影响。
2.6 很多字符串操作,比如getBytes()/String(byte[] bytes) 等都是隐含着使用平台默认编码,这是一种好的实践吗?是否有利于避免乱码?
答:不是一种好的实践。因为如果getBytes()方法根据当前平台默认编码格式获取字节数组,Java中一个汉字字符串在gbk/gb2312占2个字节、utf-8占3个、utf-16占4个、unicode占4个,具有不确定性。所以写代码时最好指定编码,建议通过str.getBytes(“UTF-16”) 或 str.getBytes(“UTF-8”) 来指定编码方式,不带参数的getBytes方法通常是不建议使用的。如:“斗”.getBytes().length返回的是3,说明缺省编码是UTF-8。
3 Java String可以有多长?
答:由一个问题,我们可以延伸出这么多问题
(1)String有多长是指字符数还是字节数?
(2)String有几种存在(存储)形式?
(3)String的不同形式受到何种限制?
(4)为什么String在栈中是以Utf8的数据结构去存储的?
3.1 String有多长是指字符数还是字节数?
答:在栈中得到的最大长度是字节数,在堆中得到的最大长度是字符数。
3.2 String有几种存在(存储)形式?
答:通过直接赋值和new String()的方式,最终有2种存在形式:栈和堆,
(1)当String变量是一个类中的全局变量直接赋值时,其变量是存在栈中的;
// 栈
String longStr = "aaaaa.....aaaaa";
(2)new String()时通过从文件中读取String时,String是存在堆中。
// 堆
byte[] strByte = loadFromFile(new File("String.txt"));
String longStr = new String(strByte);
3.3 String的不同形式受到何种限制?
答:
3.3.1 当String存在栈中时
// 栈
String longStr = "aaaaa.....aaaaa";
(1)字节码中CONSTANT_Utf8_info的限制
当String变量是一个类中的全局变量直接赋值时,其变量是存在栈中的,这时String类型可存储的字节长度取决于.class描述全局String类型变量的数据结构。String类型变量是Utf8的数据结构去存储的,其中u2是表示一个2个字节的数据类型,这也就意味着允许的最大长度为65535个字节数。
CONSTANT_Utf8_info {
u1 tag;
u2 length; // 2个字节 1byte=8bit->2byte=16bit->2^16 2^16-1=65535 -> 0~65535
u1 bytes[length]; // 65535
}
(2)Javac 源码逻辑的限制
String的字节长度为65535,由此得出栈中String的最大长度可以装65535个字节?猜想可能是Javac编译器的bug。
答:当我们写65535个a的时候运行它居然报错:error: constant string too long。找到javac编译源码,路径:src/share/classes/com/sun/tools/javac/jvm/Gen.java,找到方法:checkStringConstant()
我们看下Pool.MAX_STRING_LENGTH是多少,路径:src/share/classes/com/sun/tools/javac/jvm/Pool.java
由此得出必须得小于65535,所以String能装65534个拉丁字符,确实是Javac编译器的bug;而Kotlin中不存在此问题,String能装65534个拉丁字符。
(3)方法区大小的限制:引发思考既然能装65534个拉丁字符,那能装多少个中文字符呢?
中文字符能装65535/3个。我们再一起探索中文字符,我们再次翻出javac源码进行探索,路径:src/share/classes/com/sun/tools/javac/jvm/ClassWriter.java,找到writePool方法
看到这里写的是bs.length > Pool.MAX_STRING_LENGTH 就会抛出异常,那么证明中文字符是可以装65535/3个。
(4)结论
由此得出结论:在栈中的字符串可以装拉丁字节65534个,非拉丁字节可以装65535个,可以装中文字符65535/3个。
3.3.2 当String存在堆中时
// 堆
byte[] strByte = loadFromFile(new File("String.txt"));
String longStr = new String(strByte);
(1)受虚拟机指令限制
通过new String()创建String对象时,受到String内部实现的影响。String内部是以char数组的形式存储,数组的长度是int类型,那么String允许的最大长度就是Integer.MAX_VALUE个字符数,不过也受实际的内存影响。****
public final class String implements Serializable, Comparable<String>, CharSequence {
private final char[] value;
}
(2)受虚拟机实现限制
但是,我们从ArrayList的源码可以看出,数组可分配的最大长度应该是Integer.MAX_VALUE - 8,否则会抛出OutOfMemoryError: Requested array size exceeds VM limit错误;
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
/**
* 要分配的最大数组大小。 一些虚拟机在数组中保留一些标题字。
* 尝试分配更大的阵列可能会导致OutOfMemoryError:请求的阵列大小超出VM限制
*/
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
}
(3)受堆内存大小限制
但是实际上,如果真的执行了下面的代码,还会抛出错误java.lang.OutOfMemoryError: Java heap space,那是受到了Java堆可分配的内存大小限制,如何在编译器里修改Java虚拟机堆栈的大小,这里就不多说,主要是想说明其实还有这样一个限制因素存在。
char[] chars = new char[Integer.MAX_VALUE - 8];
3.4 为什么String在栈中是以Utf8的数据结构去存储的?
答:从String s2 = “Java”;反编译后的字节码,看出字符串的在栈中是以Utf8的数据结构去存储的。
// 直接赋值
String s2 = "Java";
// 字节码的常量池
Constant pool:
#2 = String #33 // Java
#33 = Utf8 Java
// Utf8类型的结构
CONSTANT_Utf8_info{
u1 tag;
u2 length; // 2个字节
u1 bytes[length]; // 65535
}
// Utf8类型的结构解释
3.5 学习链接
java String 到底有多长?String超出长度怎么解决?
4 StringBuffer与StringBuilder
4.1 StringBuffer与StringBuilder有什么共同点和区别?
答:(1)StringBuffer它的线程安全是通过把各种修改数据的方法都加上 synchronized 关键字实现的,非常直白。其实,这种简单粗暴的实现方式,非常适合我们常见的线程安全类实现,不必纠结于synchronized 性能之类的,有人说“过早优化是万恶之源”,考虑可靠性、正确性和代码可读性才是大多数应用开发最重要的因素;
(2)为了实现修改字符序列的目的,StringBuffer 和 StringBuilder 底层都是利用可修改的(char,JDK 9 以后是 byte)数组,二者都继承了 AbstractStringBuilder,里面包含了基本操作,区别仅在于最终的方法是否加了 synchronized。
// StringBuffer字符串变量(线程安全)是一个容器,最终会通过toString方法变成字符串;
public final class StringBuffer extends AbstractStringBuilder
implements Serializable, Appendable, CharSequence {
public StringBuffer() {
super(16);
}
public synchronized StringBuffer append(int i) {
super.append(i);
return this;
}
public synchronized StringBuffer delete(int start, int end) {
super.delete(start, end);
return this;
}
}
// StringBuilder 字符串变量(非线程安全)。
public final class StringBuilder extends AbstractStringBuilder
implements java.io.Serializable, Appendable, CharSequence {
public StringBuilder() {
super(16);
}
public StringBuilder append(String str) {
super.append(str);
return this;
}
public StringBuilder delete(int start, int end) {
super.delete(start, end);
return this;
}
}
4.1.1 这个内部数组char[]应该创建成多大的呢?
答:如果太小,拼接的时候可能要重新创建足够大的数组;如果太大,又会浪费空间。目前的实现是,构建时默认初始字符串长度是16(这意味着,如果没有构建对象时输入最初的字符串,那么初始值就是 16)。我们如果确定拼接会发生非常多次,而且大概是可预计的,那么就可以指定合适的大小,避免很多次扩容的开销。扩容会产生多重开销,因为要抛弃原有数组,创建新的(可以简单认为是倍数)数组,还要进行 arraycopy。