Java关于字符串的优化学习
String 对象是我们使用最频繁的一个对象类型,但它的性能问题却是最容易被忽略的。String 对象作为 Java 语言中重要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。
String 存储变化
随着 Java 版本的更迭,工程师们对 String 对象做了大量的优化,来节省存储空间。
1、在 Java6 以及之前的版本中,String 对象是对 char 数组进行了封装实现的对象,主要有四个成员变量:char 数组、偏移量 offset、字符数量 count、哈希值 hash。String 对象是通过 offset 和 count 两个属性来定位 char[]数组,获取字符串。这么做可以高效、快速地共享数组对象,同时节省内存空间,但这种方式很有可能会导致内存泄漏。
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence
{
/** The value is used for character storage. */
private final char value[];
/** The offset is the first index of the storage that is used. */
private final int offset;
/** The count is the number of characters in the String. */
private final int count;
/** Cache the hash code for the string */
private int hash; // Default to 0
...
}
2、 从 Java7 版本开始到 Java8 版本,Java 对 String 类做了一些改变。String 类中不再有 offset 和 count 两个变量了。这样的好处是 String 对象占用的内存稍微少了些,同时,String.substring 方法也不再共享 char[],从而解决了使用该方法可能导致的内存泄漏问题。
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
.....
}
3、从 Java9 版本开始,工程师将 char[]字段改为了 byte[]字段,又维护了一个新的属性 coder,它是一个编码格式的标识。
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
private final byte coder;
/** Cache the hash code for the string */
private int hash; // Default to 0
.....
}
至于为何要这样改?我们知道一个 char 字符占 16 位,2 个字节。这个情况下,存储单字节编码内的字符(占一个字节的字符)就显得非常浪费。JDK1.9 的 String 类为了节约内存空间,于是使用了占 8 位,1 个字节的 byte 数组来存放字符串。
而新属性 coder 的作用是,在计算字符串长度或者使用 toCharArray()函数时,我们需要根据这个字段,判断如何计算字符串长度。coder 属性默认有 0 和 1 两个值,0 代表 Latin-1(单字节编码),1 代表 UTF-16。如果 String 判断字符串只包含了 Latin-1,则 coder 属性值为 0,反之则为 1。
public char[] toCharArray() {
return isLatin1() ? StringLatin1.toChars(value)
: StringUTF16.toChars(value);
}
private boolean isLatin1() {
return COMPACT_STRINGS && coder == LATIN1;
}
static final byte LATIN1 = 0;
static final byte UTF16 = 1;
以前学习 String 源码时,只关注了 JDK8 的源码,对于 JDK9 的改动没有了解,既然把 char[] 改为了 byte[] 数组,那么我第一时间就想去了解 String 是如何存储汉字的?
关于这部分内容可以参考网友的这篇文章《Java中如何存储汉字》,通俗易懂。
String学习
关于字符串的优化,首先需要学习字符串常量池和 String 源码,在此基础上,非常容易理解字符串的优化。
这里推荐几篇我之前写的文章:
String优化
1、构建超长字符串
String str = "abcdef";
for(int i=0; i<1000; i++) {
str = str + i;
}
编译后查看字节码文件,可知上述代码等同于以下代码:
String str = "abcdef";
for(int i=0; i<1000; i++) {
str = (new StringBuilder(String.valueOf(str))).append(i).toString();
}
不过平时做字符串拼接时,尽量使用 StringBuilder,如果涉及到线程安全,则推荐使用 StringBuffer。
2、使用 String.intern 节省内存
关于 String.intern 的详细讲解可以参考 Java 基础:String——常量池与 intern。
这里只提一下何时使用 String.intern 可以节省内存,我们通过一个简单的示例代码来看一下:
String name = new String("hresh");
String copyName1 = "hresh";
String copyName2 = new String("hresh").intern();
// name == copyName1 false
// copyName2 == copyName1 true
通过 new 关键字创建 String 对象时,不管字符串常量池中是否存在“hresh”值的引用,都会在堆中创建一个值为 “hresh” 的对象,如果不调用 intern 方法,则 name 指向的是 o1 对象的引用,而非字符串常量池中关于 o2 对象的引用。如果调用了 intern 方法,则 copyName2 指向的是字符串常量池中关于 o2 对象的引用。如下图所示:
因为 o3 对象没有引用指向它,在未来的垃圾回收中会被标记,直到被回收。
那么有人可能会问,如果 copyName2 指向 o3,并且没有 copyName1 变量,那么 o2 对象是否会被回收呢?
个人理解如下:首先字符串常量池是有大小限制的,在不超限的前提下,是不会清除字符串常量池中的无用引用,那么 o2 对象就不会被垃圾回收。如果字符串常量池满了,那么可能会检查是否有变量指向该引用,如果没有则可能回收 o2 对象。
关于 intern 方法的使用注意事项:字符串常量池类似于于一个存在引用的链表,如果放进来的引用非常多,就会造成 hash 冲突,导致链表过长,当调用 String#intern() 时会需要到链表上一个一个找,从而导致性能大幅度下降。
3、慎用 Split()方法
Split() 方法使用了正则表达式实现了其强大的分割功能,而正则表达式的性能是非常不稳定的,使用不恰当会引起回溯问题,很可能导致 CPU 居高不下。我们可以用 String.indexOf() 方法代替 Split() 方法完成字符串的分割。
关于正则表达式为何会引起回溯问题,我们接下来详细介绍。
扩展
有这样一个问题:Java中的String有没有长度限制?
在 String 源码分析中,关于其构造方法的实现比较多,其中有几个是支持用户传入 length 来执行长度的:
public String(byte bytes[], int offset, int length)
可以看到,这里面的参数 length 是使用 int 类型定义的,那么也就是说,String 定义的时候,最大支持的长度就是 int 的最大范围值。
根据 Integer 类的定义,java.lang.Integer#MAX_VALUE
的最大值是2^31 – 1;
那么,我们是不是就可以认为 String 能支持的最大长度就是这个值了呢?
其实并不是,这个值只是在运行期,我们构造 String 的时候可以支持的一个最大长度,而实际上,在运行期,定义字符串的时候也是有长度限制的。
如以下代码:
String s = "11111...1111";//其中有10万个字符"1"
当我们使用如上形式定义一个字符串的时候,当我们执行 javac 编译时,是会抛出异常的,提示如下:
错误: 常量字符串过长
那么,明明 String 的构造函数指定的长度是可以支持2147483647(2^31 – 1)的,为什么像以上形式定义的时候无法编译呢?
常量池限制
原因在于: 形如String s = "xxx";
定义String的时候,xxx被我们称之为字面量,这种字面量在编译之后会以常量的形式进入到 Class 文件常量池。 要进入常量池,就要遵守常量池的约束条件。
Class 文件常量池的格式规定:其字符串常量的长度不能超过65535( 2^16 – 1 ), 当参数类型为 String,并且长度大于等于 65535 的时候,就会导致编译失败。
运行期限制
首先我们查看以下代码:
String s = "";
for (int i = 0; i <100000 ; i++) {
s+="i";
}
在运行期,长度不能超过 Int 的范围,否则会抛异常。
推荐阅读:String长度限制
总结
关于字符串的内容学习已经写了好几篇文章了,直到本篇文章结束,才感觉差不多掌握了。对一个小小的字符串了解不够深入,使用不够恰当,很可能引发线上事故。
Java 自身在迭代过程中通过不断地更改成员变量,节约内存空间,对 String 对象进行优化。我们有必要深入学习一遍 String 对象,理解其背后的知识后,使用时才会得心应手。
转载自:https://juejin.cn/post/7253813970091130941