《Java特种兵》1.1 String的例子,见证下我们的功底

本文是《Java特种兵》的样章,感谢博文视点和作者授权本站发布

1.1 String的例子,见证下我们的功底

哇塞,第1节就开始讲代码例子,受不了啦,胖哥,你坏死了!所有的书第1节都是写这个领域有什么东西的。

哈哈,小胖哥天生就是个逆天之人哦,希望你能先实践有了感性认识后,再进行理论了解内在。

下面的代码改编于网络牛人的一段程序,先看代码清单1-1。

代码清单1-1 一段String的比较程序

private static void test1() {
	String a = "a" + "b" + 1;
	String b = "ab1";
	System.out.println(a == b);
}

胖哥,你是不是考我智商呀?我们平时对比两个对象不是用equals()吗?老师告诉我:两个字符串用等号是匹配不了的,结果应该是false吧。那么结果到底是多少呢?

运行结果:

true

什么?竟然是true?为什么是true?这是要逆天吗?这小段程序彻底颠覆了我的经验和老师教我的真理!“我和我的小伙伴们惊呆了……”

胖哥告诉你这不能怪老师,老师带进门,修行靠个人!

也许有朋友做出了true或猜出了true,那么可否知道原因呢?如果你知道,那么本节跳过,无须再看;如果还不知道,就听听胖哥给你说说他所理解的原因。

要理解这个问题,你需要了解些什么?

◎ 关于“==”是做什么的?

◎ equals呢?

◎ a和b在内存中是什么样的?

◎ 编译时优化方案。

下面的内容会很多,现在我们可以站起来简单运动一下,端一杯咖啡,慢慢解读下面的内容。

†† 1.1.1 关于“==”

首先要知道“==”用于匹配内存单元上的内容,其实就是一个数字,计算机内部也只有数字,而在Java语言中,当“==”匹配的时候,其实就是对比两个内存单元的内容是否一样。

如果是原始类型byte、boolean、short、char、int、long、float、double,就是直接比较它们的值。这个大家应该都清楚,这里不再详谈。

如果是引用(Reference),比较的就是引用的值,“引用的值”可以被认为是对象的逻辑地址。如果两个引用发生“==”操作,就是比较相应的两个对象的地址值是否一样。换一句话说,如果两个引用所保存的对象是同一个对象,则返回true,否则返回false(如果引用指向的是null,其实这也是一个JVM赋予给它的某个指定的值)。

理解寓意:大家各自拿到了一个公司的offer,现在我们看看哪些人拿到的offer是同一个公司的。

†† 1.1.2 关于“equals()”

equals()方法,首先是在Object类中被定义的,它的定义中就是使用“==”方式来匹配的(这一点大家可以参看Object类的源码)。也就是说,如果不去重写equals()方法,并且对应的类其父类列表中都没有重写过equals()方法,那么默认的equals()操作就是对比对象的地址。

equals()方法之所以存在,是希望子类去重写这个方法,实现对比值的功能,类似的,String就自己实现了equals()方法。为什么要自己去实现呢?因为两个对象只要根据具体业务的关键属性值来对比,确定它们是否是“一致的或相似的”,返回true|false即可。

迷惑:equals()不就是对比值的吗?为何说相似?

答曰:一日偶遇怪侠“蜗牛大师”一枚,赐予神剑于数人,猎人将其用于打猎;农夫将它用于劈柴;将军用它保家卫国;侠客用它行侠仗义、惩奸除恶,等等。

例如:在对比一些工程的图纸尺寸的时候,由于尺寸都会存在细节误差,可以认为宽度和高度比较接近,就可以返回true,而不一定非要精确匹配。另外,图纸纸张的属性除了长度、宽度外,还有如名称、存放位置、卷号等属性,但是我们可能只需要对比它的长度与宽度,在这个范围内其余的属性不会考虑。也就是说,两个对象的值是否相同是自己的业务决定的,而不是Java语言来决定的。

感悟:变通,让标准变为价值,给你一种思想和标准,你可以有不同的使用,不能死扣定理,我们要解决问题!

迷惑:equals()重写后,一般会重写hashCode()方法吗?

要说明这个问题,我们先要补充一些概念。

Java中的hashCode是什么hashCode()方法提供了对象的hashCode值,它与equals()一样在Object类中提供,不过它是一个native(本地)方法,它的返回值默认与System.identityHashCode(object)一致。在通常情况下,这个值是对象头部的一部分二进制位组成的数字,这个数字具有一定的标识对象的意义存在,但绝不等价于地址。

hashCode的作用它为了产生一个可以标识对象的数字,不论如何复杂的一个对象都可以用一个数字来标识。为什么需要用一个数字来标识对象呢?因为想将对象用在算法中,如果不这样,许多算法还得自己去组装数字,因为算法的基础是建立在数字基础之上的。那么对象如何用在算法中呢?

例如,在HashMap、HashSet等类似的集合类中,如果用某个对象本身作为Key,也就是要基于这个对象实现Hash的写入和查找,那么对象本身如何能实现这个呢?就是基于这样一个数字来完成的,只有数字才能真正完成计算和对比操作。

hashCode只能说是标识对象,因此在Hash算法中可以将对象相对离散开,这样就可以在查找数据的时候根据这个key快速地缩小数据的范围。但不能说hashCode值一定是唯一的,所以在Hash算法中定位到具体的链表后,需要进一步循环链表,然后通过equals()来对比Key的值是否是一样的。这时hashCode()与equals()似乎就成为“天生一对”。换句话说,一个是为了算法快速定位数据而存在的,一个是为了对比真实值而存在的。

与equasls()类似,hashCode()方法也可以重写,重写后的方法将会决定它在Hash相关数据结构中的分布情况,所以这个返回值最好是能够将对象相对离散的数据。如果发生一个极端的情况,即hashCode()始终返回一个值,那么它们将存在于HashMap的同一个链表中,将会比链表查询本身还要慢。

在JDK 1.7中,Hash相关的集合类对使用String作为Key的情况,不再使用hashCode方式,而是有了一个hash32属性,其余的类型保持不变。

换个思路,hashCode()与equals()并不是必须强制在一起,如果不需要用到这样的算法,也未必要重写对应的方法,完全由你自己决定,没有语法上强制的规约。

寓意:此好比,宝剑是否需要有剑鞘?宝贝是否需要有宝箱?雄性是否必须需要雌性?地球是否需要有月亮?

而并非是,树是否需要土壤?生命是否需要食物?鱼儿是否必须需要水?世界是否需要阳光?

感悟:一切在于场景与需求,十分需要,但也可以在某些情况下放弃。

有人说:对比两个对象是否一致,可以先对比hashCode值再对比equals()。

这似乎听上去挺有道理的,但是胖哥本人可并不这么认为!为什么呢?胖哥认为hashCode值根本不是为了对比两个对象是否一致而存在的,可以说它与两个对象是否一致“一点关系都没有”。

假如你希望对比两个对象是否是同一个对象,则完全可以直接用“==”来对比,而不需要用hashCode(),因为直接对比地址值说明两个对象是否为同一个对象才是最靠谱的。另外,默认的hashCode()方法还会发起native调用,并且两个对象都会分别发起native调用(native调用的开销也是不小的)。

假如不是对比地址,而是对比值,自然就需要对象中的某些属性来对比。拿String类型的对象来讲,如果调用某个String对象的hashCode()方法,它至少会在第1次调用这个方法时遍历所有char[]数组相关元素来计算hashCode值(这里之所以说至少,是因为这个String如果并发调用hashCode()方法,则可能被遍历多次),遍历过程中还需要外加一些运算。如果对比的两个对象分别获取hashCode,自然两个String对象都会分别遍历一次char[]数组。

即使hashCode一样了,也同样证明不了这两个String是一样的(这个hashCode是根据char[]数组的值算出来的,不同的String完全可以算出一样的值),还得再次循环两个String中所有的字符来对比一次才能证明两个对象是一样的。其实遍历了两个char[]数组已经是最坏的情况了,equals()还未必会这样做(在后文的图1-2中会详细说明)。

换一个角度来讲,如果是两个其他的自定义类型的对象(不是String类型的对象)之间判定出来hashCode不一样,也不能说它们的值不一样(有可能equals()匹配的是一个综合值,与hashCode一点关系都没有),还是要进行equals(),这样绕来绕去往往是把简单问题复杂化了。

equals()内部要怎么做就去怎么做嘛,想要优化,完全可以像JDK自带的许多类那样,先对比一些简单的属性值,再对比复杂的属性值,或者先对比业务上最快能区分对象的值,再对比其他的值,或者先对比地址、长度等处理方式,将那些不匹配的情况尽快排出。

有人说重写后的hashCode()内部操作确实比equals()简单许多倍,其实这个动作判定也是可以放在equals()方法的第1步中来完成的,无须外部程序关注。

补充:String的equals()方法中默认就要先对比传入的对象与当前的this是不是同一个对象。在HashMap等集合类的查找过程中,也不是单纯的equals(),也会先对比两个对象是不是同一个对象。

好累,休息休息!左三圈、右三圈,再来看看胖哥为你做解读!

a和b的内存情况是什么样的?

回到“代码清单1-1”的例子中,其中的等号说明a和b是指向同一块内存空间的,就像两个人拿到同一个公司的Offer一样,他们像什么呢?见图1-1,“死冤家,又在一起了!”

D3[LWJEVP4{GK(5ND$P4~82

图1-1 两个冤家又拿到同一个公司的Offer

为什么a、b两个引用都引用到同一块空间了呢?请看1.1.3节的内容解释,不过在这一节中我们先感性认识下JVM的一些“东东”,在第3章中会有更详细的介绍。小伙伴们不要着急,我们一步步来学习。

†† 1.1.3 编译时优化方案

a引用是通过“+”赋值的,b引用是直接赋值的,那为什么a和b两个引用会指向同一个内存单元?这就是JVM的“编译时优化”。如此神奇!小伙伴们惊呆了吧!

当编译器在编译代码:String a = “a” + “b” + 1;时,会将其编译为:String a = “ab1”;。

为何?因为都是“常量”,编译器认为这3个常量叠加会得到固定的值,无须运行时再进行计算,所以就会这样优化。

疑惑:编译器为何要做此优化?

寓意:“小胖”说我的报销单写好了并盖章了,“小明”说我的也OK了,那么就合并一起邮寄报销单吧。“小锐”说我的快写好了,不过还没盖章,那你写好后再说吧。

寓意:为提升整体工作效率和节约资源,能提前做的事情就提前做。我们自己设计一种平台或语言的时候是否会考虑这些呢?

补充:编译器类似的优化还有许多(在后文中会有介绍),例如,当程序中出现int i = 3 * 4 + 120时,并不是在实际运行时再计算i的值,而是在编译时直接变成了i = 132。

容易出错:JVM只会优化它可以帮你优化的部分,它并不是对所有的内容都可以优化。例如,就拿上面叠加字符串的例子来说,如果几个字符串叠加中出现了“变量”,即在编译时,还不确定具体的值是多少,那么JVM是不会去做这样的编译时合并的。而JVM具体会做什么样的优化,不做什么样的优化,需要我们不断去学习,才能把工作做得更好。

同理证明的道理:String的“+”操作并不一定比StringBuilder.append()慢,如果是编译时合并就会更快,因为在运行时是直接获取的,根本不需要再去运算。同理,千万不要坚定地认为什么方式快、什么方式慢,一定要讲究场景。而为什么在很多例子中StringBuilder. append()比String的“+”操作快呢?在后文中,胖哥会继续介绍原因。

†† 1.1.4 补充一个例子

胖哥,我开始有点兴趣了,不过感觉在String方面还没过瘾!

OK,胖哥就再补充一个例子。

代码清单1-2 String测试1

private static String getA() {return"a";}
public static void test2() {
	String a = "a";
	final String c = "a";

	String b = a + "b";
	String d = c + "b";
	String e = getA() + "b";

	String compare = "ab";
	System.out.println(b == compare);
	System.out.println(d == compare);
	System.out.println(e == compare);
	}

这段代码是说编译优化,看看输出是什么?
false

true

false
看到这个结果,如果你没有“抓狂”,一种可能是你真的懂了,还有一种可能就是你真的不懂这段代码在说什么。在这里,胖哥就给大家阐述一下这个输出的基本原因。

第1个输出false。

“b”与“compare”对比,根据代码清单1-2中的解释,compare是一个常量,那么b为什么不是呢?因为b = a + “b”,a并不是一个常量,虽然a作为一个局部变量,它也指向一个常量,但是其引用上并未“强制约束”是不可以被改变的。虽然知道它在这段代码中是不会改变的,但运行时任何事情都会发生,尤其是在“字节码增强”技术面前,当代码发生切入后,就可能发生改变。所以编译器是不会做这样优化的,所以此时在进行“+”运算时会被编译为下面类似的结果:

StringBuilder temp = new StringBuilder();
temp.append(a).append("b");
String b = temp.toString();

注:这个编译结果以及编译时合并的优化,并非胖哥凭空捏造的,在后文中探讨javap命令时,这些内容将得到实际的印证。

第2个输出true。

与第1个输出false做了一个鲜明对比,区别在于对叠加的变量c有一个final修饰符。从定义上强制约束了c是不允许被改变的,由于final不可变,所以编译器自然认为结果是不可变的。

final还有更多的特性用于并发编程中,我们将在第5章中再次与它亲密接触。

第3个输出false。

它的叠加内容来源于一个方法,虽然方法内返回一个常量的引用,但是编译器并不会去看方法内部做了什么,因为这样的优化会使编译器困惑,编译器可能需要递归才能知道到底返回什么,而递归的深度是不可预测的,递归过后它也并不确保一定返回某一个指定的常量。另外,即使返回的是一个常量,但是它是对常量的引用实现一份拷贝返回的,这份拷贝并不是final的。

也许在JIT的优化中会实现动态inline(),但是编译器是肯定不会去做这个动作的。

疑惑:我怎么知道编译器有没有做这样的优化?

答曰:懂得站在他人角度和场景看待事物的不同侧面,加以推导,结合别人的意见和建议,就有机会知道真相。

寓意:如果我是语言的作者,我会如何设计?再结合提供验证、本质、文档,共同引导我们了解真相。

理解方式:编译器优化一定是在编译阶段能确定优化后不会影响整体功能,类似于final引用,这个引用只能被赋值一次,但是它无法确定赋值的内容是什么。只有在编译阶段能确定这个final引用赋值的内容,编译器才有可能进行编译时优化(请不要和运行时的操作扯到一起,那样你可能理解不清楚),而在编译阶段能确定的内容只能来自于常量池中,例如int、long、String等常量,也就是不包含new String()、new Integer()这样的操作,因为这是运行时决定的,也不包含方法的返回值。因为运行时它可能返回不同的值,带着这个基本思想,对于编译时的优化理解就基本不会出错了。

†† 1.1.5 跟String较上劲了

看到这里你是否觉得自己对天天使用的String还不是特别了解,而胖哥之所以不断谈到String的一些细节,不仅仅是要说String本身有什么样的特征,在Java语言中还有许许多多的“类”值得我们去研究。

如果你觉得还没过瘾,和String较劲上了,那么关于String我们再来举个例子,然后胖哥做个小小总结,这里的故事就结局了。

代码清单1-3 String测试2

public static void test3() {
	String a = "a";
	String b = a + "b";
	String c = "ab";
	String d = new String(b);
	println(b == c);
	println(c == d);
	println(c == d.intern());
	println(b.intern() == d.intern());
}

这段代码与上一个例子类似,只是增加了intern()的调用。同样的,我们可以先看看输出是什么。

false

false

true

true

从输出上看规律,胖哥相信有不少小伙伴们应该能猜到是intern()方法在起作用。是的,没错,就是它。

HotSpot VM 7及以前的版本中都是有Perm Gen(永久代)这个板块的(第3章中详述),在前面例子中所谈到的String引用所指向的对象,它们存储的空间正是这个板块中的一个“常量池”区域,它对于同一个值的字符串保证全局唯一。

如何保证全局唯一呢?当调用intern()方法时,JVM会在这个常量池中通过equals()方法查找是否存在等值的String,如果存在,则直接返回常量池中这个String对象的地址;若没有找到,则会创建等值的字符串(即等值的char[]数组字符串,但是char[]是新开辟的一份拷贝空间),然后再返回这个新创建空间的地址。只要是同样的字符串,当调用intern()方法时,都会得到常量池中对应String的引用,所以两个字符串通过intern()操作后用等号是可以匹配的。

回到代码清单1-3的例子中,字符串”ab”本身就在常量池中,当发生new String(b)操作时,仅仅是进行了char[]数组的拷贝,创建了一个String实例。当这个新创建的实例发生intern()操作时,在常量池中是能找到这个对象的,其余的内容,依此类推,可以得到为什么会这样的答案。

此时,能否有所感悟,没有的话没关系,这是第1节,胖哥带你一起感悟:

◎ String到底还有多少方法我没见过?

◎ intern()有何用途?有什么坑?它是否会在常量池中被注销?

◎ 是否开始觉得这些东西复杂,又像是回到最简单的道理上来了?

◎ 是否认为自己的功底需要细化?

†† 1.1.6 intern()/equals()

String可能有很多的方法还没见过,在研究清楚String之前,还得看不少其他的代码。String并没有我们想的那么简单,而且它在我们的工作中无处不在,所以需要去学习它。

胖哥在本节中也只是给大家讲个大概,String中的许多奥秘,会在本书后面的章节中不时出现,大家留意哦。

如果仔细看了对intern()的文档描述,就应该知道intern()本身的效率并不高,它虽然在常量池中,但它也需要通过equals()方法去对比,在常量池中找是否存在相同的字符串才能返回结果。显然,这个过程中对比的通常不止一个字符串,而是多个字符串,效率自然要更低一些。另外,它需要“保证唯一”,所以需要有锁的介入,效率自然再打折扣。因此,直接使用它循环对比得出的效率通常会比循环equals()的效率低。但这并不是说对比地址比equals()要慢一些,它是输在了对比地址之前需要先找到地址上。

它的效率低是相对equals()来讲的,如果要细化认识这两者的效率并应用在实践中,那么我们要先看看String.equals()方法的实现细节,请看图1-2。

W6KLZ]~JU3VRO4OEGKJS0FV

图1-2 String.equals()源码

不论是JDK 1.6还是1.7,String.equals()的代码逻辑大致归纳如下。

(1)判定传入的对象和当前对象是否为同一个对象,如果是就直接返回true(就像在代码清单1-1中一样,两个人拿到同一个公司的offer)。

(2)判定传入对象的类型是否为String,若不是则返回false(如果是null也不会成立)。

(3)判定传入的String与当前String的长度是否一致,若不一致就返回false。

(4)循环对比两个字符串的char[]数组,逐个对比字符是否一致,若存在不一致的情况,则直接返回false。

(5)循环结束都没有找到不匹配的,所以最后返回true。

由此,我们至少得出两个最基本的结论。

(1)要匹配上,就要遍历两个数组,逐个下标匹配,这是最坏的情况。没想到吧,匹配上了还是最坏的情况。那么对于大字符串来讲,这件事情是悲剧的。

(2)字符串首先匹配了长度,如果长度不一致就返回false。这对于我们来讲也是乐观的事情,如果真的遇上大字符串匹配问题,也许就有其他的技巧性方法哦。

好了,我们回过头来看看intern()比equals()还慢的问题。这个理论结果,可以用循环多次equals(),然后循环多次intern(),最后做“==”匹配来测试,得到的直接结论是:使用equals()效率会更高一些。一些曾经认为intern()对比地址效率高的“技术控”同学们,有点傻眼了。

而intern()也并非无用武之地,它也可以用在一些类似于“枚举”的功能中,只是它是通过字符串来表达而已(其实枚举在底层的实现也是字符串)。

例如,在某种设计中会涉及很多数据类型(如int、double、float等),此时需要管理这些数据类型,因此通常是以字符串方式来存储的。当需要检索数据时,通过字典会得到它们的类型,然后根据不同的类型做不同的数据转换。这个动作最简单的方法可能就是用一个类型列表for循环equals()匹配传入的类型参数,匹配到对应的类型后就跳转到对应的方法中。如果类型有上百种之多,自然equals()的次数会非常多,而equals()的效率很多时候其实不好说,或许有些时候可以换个思路来做。

在加载数据字典的类型时就直接intern()到内存中(因为这些类型是固定的),在真正匹配数据类型的时候就是“常量比常量”,对比地址,这样比较就快速多了,因为它不会使用equals(),也不是匹配的时候再去调用intern()。

举个例子:假如记录的是数据库的数据类型。这个中间平台在执行某些操作时需要检测每个表的元数据的每个列的类型,在很多时候这些元数据可能会被事先加载到内存中(前提是元数据不是特别多),并且以intern()方式直接注入到常量池中,那么在后面逐个列的类型匹配过程中就不用equals()了,而是直接用“==”,因为这些内容已经在常量池中了。

在这样的情况下,不同的表和属性如果类型是相同的,那么它们所包含的字符串对象也会是同一个,同时也可以在一定程度上节约空间。

这个方法比枚举要“土”一些,但是它也是一种方案,我们完全可以用枚举来实现,而枚举内在的实现也是类似的方式,只是它是“虚拟机”级别自带的而已。

intern()注入到常量池中的对象和普通的对象一样占用着相应的内存空间,唯一的区别是它存储的位置是在Perm Gen(永久代)的常量池中。永久代会注销吗?会的,它也会被注销,在FULL GC的时候,如果没有任何引用指向它则会被注销。关于永久代的一些故事,我们还是放到第3章来讨论细节吧。

本章探讨的intern()仅仅针对JDK 1.6环境,如果将环境更换为JDK 1.7,String pool将不会存在Perm Gen当中,而是存在堆当中,部分代码的测试结果将有可能更加诡异,请大家悉知。(在JDK 1.7环境下运行本章给出的例子,结果会有少数不一致,胖哥在本书光盘相应的测试类中增加了testForJDK17()方法,大家可以单独看看,在JDK 1.7下可能还会有意想不到的效果。)

†† 1.1.7 StringBuilder.append()与String“+”的PK

在很多的书籍和博客中,会经常看到某些小伙伴们用一个for循环几千万次,来证明StringBuilder.append()操作要比String“+”操作的效率高出许多倍。其实胖哥并不这么认为,为何呢?因为胖哥知道这是怎么发生的,下面就跟着胖哥的思路来看看吧。

胖哥认为:如果一件事物失去了价值,它就没有存在的必要,所以它存在必有价值。

在前面的例子中,String通过“+”拼接的时候,如果拼接的对象是常量,则会在被编译器编译时合并优化,这个合并操作是在编译阶段就完成的,不需要运行时再去分配一个空间,自然效率是最高的,StringBuilder.append()也不可能做到。如果是运行时拼接呢?在后面内容的学习中,我们会发现一个Java字符串进行“+”操作时,在编译前后大概是如下这样的。

原始代码为:

String a = "a";

String b = "b";

String c = a + b + "f";

编译器会将它编译为:

String a = "a";

String b = "b";

StringBuilder temp = new StringBuilder();
temp.append(a).append(b).append("f");
String c = temp.toString();

<strong>注:</strong>为了说明实际问题,胖哥才用Java代码来说明字符串拼接中“+”的真正道理,在实际的场景中这是class字节码中的内容,而这个temp是胖哥虚拟出来的。

如果将String的“+”操作放在循环中,那么自然在循环体内部就会创建出来无穷多的StringBuilder对象,并且执行append()后再调用toString()来生成一个新的String对象。这些临时对象将会占用大量的内存空间,导致频繁的GC。我们用图1-3来描述循环叠加字符串编译前后的代码对比。

图1-3 循环叠加字符串编译前后的代码对比
UWVFIPC_P@2YLO9D[_AYNKE
在循环过程中,a引用所指向的字符串会越来越大,这就意味着垃圾空间也会越来越大。当这个a所指向的字符串达到一定程度后必然会进入Old区域,若它所占用的空间达到Old空间的1/4,需要再次分配空间的时候,就有可能发生OOM,而最多达到1/4的时候就肯定会发生OOM。为何是1/4?我们得先看看StringBuilder做了些什么。

上面的代码在循环内部会先分配一个StringBuilder对象,这个对象内部会分配16个长度的char[]数组,当发生append()操作时,如果空间够用,就继续向后添加元素;若空间不够用,则会尝试扩展空间。扩展空间的规则是:使用当前“StringBuilder的count值 + 传入字符串的长度”作为新的char[]数组的参考值,这个参考值将会与StringBuilder的char[]数组总长度的2倍来取最大值,也就是最少会扩展为原来的2倍。

count值并不是char[]数组的总长度,而是当前StringBuilder中有效元素的个数,或者说是通过StringBuilder.length()方法得到的值。char[]数组的总长度可以通过capacity()方法获取到。

关于StringBuilder.append()功能的说明,我们来看看它的源码实现就更加清楚了,如图1-4所示。
A@NLIXPM529CI1()1OE)_`O

图1-4 StringBuilder.append()的内存扩展

回到前面提到的“循环叠加字符串”的例子中,每次扩容会最少扩容2倍,而在每次扩容前,原来的对象还需要暂时保留,那么至少在某个时间点需要3倍的内存空间,自然JVM的Young空间的Survivor区域很快装不下,要让它进入Old区域。

当a引用的对象空间达到了Old区域的1/4大小时,同样会先分配一个StringBuilder对象,初始化的长度也是16个长度的char[]数组。这个StringBuilder首先会进行append(a)操作,在这次append操作时发现空间不够大,需要分配一个更大的空间来存放数据,但是这个字符串本身还不能被释放掉。

此时,字符串已经很大,那么肯定它不会只有16个长度,根据上面对append()源码的描述,新分配的空间应当是a引用当前所引用的字符串的长度(因为当前的count值应当是0,而同时也可以发现,其实StringBuilder内部已经没有空闲区了),所以Old区域最少占用了1/4 + 1/4 = 1/2的大小。

还没有完,还需要append(随机字符串)来操作,此时StringBuilder是没有空闲区域的,那么自然空间是不够用的,又会涉及扩容的操作(只要随机字符串有1个字符),扩容最少是原来的2倍,就需要1/4 *2=1/2的空间,那么剩余的一半Old空间就又被用掉了。

分配一个2倍大小的空间,需要将数据先拷贝过来,原来的空间才能释放掉。但是这个时候还没分配出来,原来的空间自然释放不掉,那么Old * 1/4大小的String空间再一次叠加的时候就有可能将整个内存“撑死”。

假如append()的随机字符串是空字符串” “,就不会发生扩容,这样是不是就不会发生OOM了呢?这还没有完哦,最后还需要toString()方式来创建一个新的String对象,这个过程会开辟一个同样大小的char[]数组将内容拷贝过去,也就是说,如果拼接前字符串所占用的空间已经达到了Old区域的1/3大小,就肯定会发生OOM了。如果你非要想想,这个时候的空间是不是就差“几个字节”就不会发生OOM呢?但是要考虑到下一轮循环是100%会发生OOM的。

不过,大家不要因为后面还有一次toString()需要分配空间,就认为前一种情况在内存空间1/5的时候就可以导致OOM,要知道前面在发生扩容的时候,如果扩容成功,原来的数组就可以被当成垃圾释放掉了(这是在StringBuilder内部替换char[]的时候发生的)。也就是说,原来的数组生命周期不会到toString()这一步。

这里的例子是假设JVM所有的内存给一个String来使用,在实际的场景中肯定不是这样的,肯定会有其他的开销,因此内存溢出的概率会更高。

再回过头来看看,用StringBuilder来写测试代码时是怎么写的。

StringBuilder builder = new StringBuilder();
for(…) {
  builder.append(随机字符串);
}

这段代码中的Stringbuilder对象只有一个,虽然内部也只是先分配了16个长度的char[]数组,但是在扩容的时候始终是2倍扩容。更加重要的是,它每次扩容后都会有一半的空间是空闲的,这一半的空间正好是扩容前数组的大小。

由于多出来的一半空间,通常不会在每次append()的时候发生扩容,而且对象空间越大,越不容易发生扩容。更不会发生的是,每次操作都会申请一个大的StringBuilder对象并将它很快当成垃圾,并且在操作过程中还对这个很大的StringBuilder做扩容操作。

这种写法,最坏的情况是它所占用的内存空间接近Old区域1/3的时候发生扩容会导致OOM(这里和String的拼接有点区别,如果遇到类似的差几个字节则导致OOM,此时String的拼接在下一轮循环时必然会发生OOM,但是StringBuilder扩容后会经历很长的一个过程不再发生扩容)。

另外,在StringBuilder.append()中间过程中产生的垃圾内存大多数都是小块的内存,它所产生的垃圾就是拼接的对象以及扩容时原来的空间(当发生String的“+”操作时,前一次String的“+”操作的结果就成了垃圾内存,自然垃圾会越来越多,而且内在扩容还会产生更大块的垃圾),在这种场景下,通常使用StringBuilder来拼接字符串效率会高出许多倍。

所以,首先确认一点,不是String的“+”操作本身慢,而是大循环中大量的内存使用使得它的内存开销变大,导致了系统频繁GC(在很多时候程序慢都是因为GC多造成的),而且是更多的FULL GC,效率才会急剧下降。

但是回过头来想一想,在实际的工作场景中很少会遇到for循环几百万次去叠加字符串的情况,虽然许多不同的线程叠加字符串的次数加在一起可能会有几百万次,但是它们本身就会开辟出不同的空间(使用StringBuilder也是每个线程单独使用自己的空间,如果是共享的就存在并发问题)。总而言之,如果是少量的小字符串叠加,那么采用append()带来的效率提升其实并不会太明显;但是如果遇到大量的字符串叠加或大字符串叠加的时候(这些字符串不是常量字符串),那么使用append()来拼接字符串效率的确会更高一些。

在JVM中,提倡的重点是让这个“线程内所使用的内存”尽快结束,以便让JVM认为它是垃圾,在Young空间就尽量释放掉,尽量不要让它进入Old区域。一个很重要的因素是代码是否跑得够快,其次是分配的空间要足够小(这些内容将在第3章中探讨)。

StringBuilder的使用也是有“坑”的。上面已经看到StringBuilder内部也可能会创建新的数组,并不断将原来的数组数据拷贝到新的数组中,虽然不会像“+”那样每次操作都会出现拷贝,但是同样会出现很多的内存碎片。

如果你要优化到极致,则需要深知业务细节,申请的空间尽量确保有很少的拷贝和碎片,甚至于没有,例如:new StringBuilder(1024)。如果你是一个高手,对代码有洁癖,那么扩展StringBuilder实现的方法也是同样可以的。但是如果你不是很清楚具体的业务,则最好直接用默认的方式,其实它很多时候未必真的那么节约内存。为什么呢?

如果拼接的字符串不足1024个或差距很大,那么肯定是浪费空间的。换个角度,如果是append(a).append(b)操作,a的长度是2,b的长度是1023,扩容是必然的,只是扩容前需要释放掉的char[]数组长度是1024(2KB以上的空间),而且扩容后的长度是2048(分配4KB以上的空间)。如果使用默认的new StringBuilder(),也会发生扩容,扩容前需要释放掉的char[]数组空间长度是16(抛开头部的32字节),而新分配的char[]数组长度是1025而不是2048。

这也再次说明世事无绝对,一定要看场景,没有什么写法是绝对好的,使用这种方式,通常是希望在字符串拼接的过程中,将中间扩容降低到最少。

在类似的问题上,也有人问过胖哥:多个字符串拼接时,有的字符串很大,有的字符串很小,是先append大字符串还是小字符串呢?胖哥也不能给出明确的答案,只能说有一些不同的情况。

先添加小字符串,再添加大字符串,在什么时候比较好用呢?小字符串不是特别多,大字符串就一两个。在这种情况下,如果先append大字符串,在扩容的时候,可能不会进行2倍扩容,而是选择当前的count值+大字符串的长度来扩容,而且扩容后是没有剩余空间的,此时再来append小字符串,也会发生2倍扩容,自然浪费的空间会很多。如果先append小字符串,可能就扩展到几十个字符或几百个字符,几次扩容就可以搞定,而且扩容过程中的垃圾内存都是很小块的。由于板块小扩容的过程也是迅速的,后面append大字符串该有的扩容开销依然有,只是通常不会选择2倍扩容而已。

如果要添加许多小字符串(例如上千个小字符串),大字符串也不是太多,此时如果先append大字符串,在发生第1个小字符串append时,就会进行2倍扩容,那么就会有一半的剩余空间,这个剩余空间可以容纳非常多的小字符串,自然扩容开销就要小很多。反过来,如果先append许许多多的小字符串,由于初始化只有16个长度的char[]数组,那么上千个小字符串叠加起来可能会发生10次左右的扩容,而扩容的空间也会越变越大。

关于String的“+”的补充说明

上面提到String的“+”会创建StringBuilder对象,然后再操作,那么粒度是多大呢?并不是“+”相关的两个String为粒度,而是一行代码为粒度。什么叫作一行代码为粒度呢?例如:

   String str = a + b + c + d + e;

这就是一行,它只会申请一个StringBuilder执行多次append()操作,然后将其赋值给str引用,如果换成了两条代码就会申请多个。

但是这个“一行”并不是指Java代码中的一行,因为我们可以把Java代码中的很多行一层层嵌套到一条代码中,其实实际运行的代码还是多行,而是指可以连续在一起的代码。

关于内存拷贝和碎片,在本书3.5节中会有非常详尽的解释和说明。

本节胖哥用了十分简单的3个代码例子来说明有许多基础内容是值得我们去研究的。不知你是否能体会到其中的变化都是使用许多简单的基础知识加以变通的结果,这种变化是十分多的,但“万变不离其宗”。

本节从某种意义上印证了功底的重要性,即明白基本是明白内在的一个基础,上层都是由此演变而来的。

同样的,大家可以自己去研究String的其他方法,例如startsWith()、endsWith()是如何实现的;如果代码中出现了反反复复这样的操作,是否有解决的思路。比如说要对每个请求的后缀进行endsWith()处理,自然就需要与许多的后缀循环进行匹配,而每个匹配都可能会去对比里面的许多字符,那么也许可以用其他的方法来处理。

本节胖哥是否对String做了一个诠释呢?没有,肯定没有,也不会!

本节所提到的内容,仅仅是想让你客观认识一下自己是否需要补充一些基础知识,希望你以此为例,学会看内在、看源码,活学活用。如果你认为自己“需要补充功底”了,胖哥的目的就达到了,此书分享过去的我的那些事,献给还在成长的你。

本节的故事到这里就结束了,胖哥不知道你是否满意,如果满意则祝愿你做一个美梦,以更好的心情和心态学习下一节的内容。

原创文章,作者:kepupublish,如若转载,请注明出处:https://blog.ytso.com/126260.html

(0)
上一篇 2021年8月30日
下一篇 2021年8月30日

相关推荐

发表回复

登录后才能评论