《Java特种兵》1.5 功底补充

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

1.5 功底补充

看完1.4节,发现胖哥废话很多,貌似没啥干货了!

为了不让大家认为功底只有String那么一点点东西,胖哥就再增加对原生态类型、集合类的说明,这两方面的内容相信所有的Java开发者都必然会用到。

†† 1.5.1 原生态类型

原生态类型是“神马”?

原生态类型就是Java中不属于对象的那5%部分。

那到底是哪些东西呢?

包含:boolean、byte、short、char、int、float、long、double这8种常见的数据类型(Primitive)。

好麻烦,为啥会用它们呢?用对象不可以吗?

计算机中的运算基础都来源于简单数字,包括Java,即使是包装后的对象(Wrapper),在真正计算的时候也是通过内在的数字来完成的,Java失去了它们,就好比鱼儿失去了水一样,失去了生命力。

它与包装后的对象有什么区别呢?

包装后的对象会按照对象的规则存储在堆中(例如,int所对应的就是Integer类的对象),而“线程栈”上只存储引用地址。对象自然会占用相对较大的空间存放在堆中,在原生态类型中,“栈”上直接保存了它们的值,而不是引用(Reference)。

下面看个例子。

代码清单1-5 一个Integer的简单测试

public static void main(String []args) {
	Integer a = 1;
	Integer b = 1;
	Integer c = 200;
	Integer d = 200;
	System.out.println(a == b);
	System.out.println(c == d);
}

输出结果:

true

false

这个结果在较低版本的JDK当中不会出现。

现在胖哥来解释一下这个结果。

在编译阶段,若将原始类型int赋值给Integer类型,就会将原始类型自动编译为Integer.valueOf(int);如果将Integer类型赋值给int类型,则会自动转换调用intValue()方法,如果Integer对象为空,这时会在自动拆箱的时候抛空指针(这个自动转换可以通过后文中介绍javap命令的方法来证明)。

这些赋值操作可能不是那么明显,例如一些集合类的写入、一些对比操作,这就需要我们知道什么时候会自动拆装箱。换句话说,它简化了代码,但是并不是让我们一无所知。

即使是这样,两个结果也应该一样,要么都是true,要么都是false,但为何不一样呢?这算是一个Java API的坑,如果我们不知道这些坑,稍微不留神就会掉进去。知道了装箱是Integer.valueOf(int)方法,那么就来看看Integer.valueOf(int)方法的源码,如图1-8所示。

ADR90{5E5RLQEMNZO{T0P62

图1-8 Integer.valueOf(int)方法的源码截图

根据代码可以看出,当传入i的值在[-128, IntegerCache.high]区间的时候,会直接读取IntegerCache.cache这个数组中的值。

在代码中为什么使用i + 128作为数组的下标呢?

因为数组下标是从0开始的,而表示的数字范围是从-128开始的,加上128就正好对上了。

继续跟踪源码可以得到,在默认情况下IntegerCache.high是127。也就是说,如果传入的int值是-128~127之间的数字,那么通过Integer.valueOf(int)得到的对象是被cache的,自然的,对于同一个数字cache的对象是同一块内存地址,所以第1个输出结果是true。第2个输出已经不在这个范围,因此会重新new Integer(int)(创建一个新对象),所以得到的结果是false。

我们可以通过设置JVM启动参数-Djava.lang.Integer.IntegerCache.high=200来间接设置IntegerCache.high值,也可以通过设置参数-XX:AutoBoxCacheMax来达到目的(这个不用查官方资料,看看源码以及源码周边的注释就懂了)。如果要将这个值变得更大来满足自己的需求,则可以在启动参数中增加该值(缩小也是一样的道理)。

这好像是做好事,将我们的数据cache起来,更加节约空间了,但是有的同学开始认为Integer可以用“==”匹配了,因为大家自己“测试”的时候发现1、2、3、4等数据都是没有问题的,但是程序发布后出现了诡异的问题,而这个最不容易被认为是问题的地方却真的成了问题。而Java API中没有明确地说明这一点,但开发人员不会将官方文档都学习一遍再来做开发吧,所以我们说它是“坑”。

有人问:真正的场景中会这样吗?

胖哥认为:肯定会,而且你遇不到的场景并不代表不会发生,今天遇不到的事情并不代表明天不会发生。例如,在某些工程设计中,某些状态值有特殊的意义,如果它们是非连续排布的,那么不在-128~127范围内的可能性是肯定存在的。

这个例子很简单,我们学到的应该不止这些,因为坑无处不在,我们要学会看源码和本质;否则,即使是Java本身提供的API出现了“坑”,也会让我们“防不胜防”,在技术面前变得十分“可怜”。

我们可以说这个API写得不好,没有说明详细的使用情况,但是一个老A不应当被“武器”所玩弄,而是要驾驭武器,老A即使拿一把普通步枪也同样能战胜拿着“狙击步枪”的普通士兵,因为他们除了拥有极强的战斗素质外,还深知武器的脾气与秉性,这是人与武器之间的驾驭和被驾驭关系。

也许自动拆装箱还有另一个很大的“坑”,就是如果大家不知道自动拆装箱是怎么完成的,可能就会有更多的问题发生,在程序中传递参数可能一会用Integer类型,一会用int类型,自然的就一会在做拆箱操作,一会在做装箱操作,这貌似没有太大的问题,但每次装箱的时候都有可能会创建一个对象(因为很多时候数字不一定在cache范围内,较低版本的JDK是没有cache的)。另外,这种装箱操作是很隐藏的。例如,我们想要用一个int类型的数字来作为HashMap的Key,那么在put()操作的时候就会自动发生装箱操作(因为Key被认为是Object的,HashMap需要获取这个对象的hashCode()方法来做离散规则,所以它会自动转型为Integer)。同样的,如果想将许多基本类型的数据放在List里面,在add()操作的时候也会自动发生装箱操作。此时,如果数据取出来后变成了基本类型,再用这个基本类型放入另一个集合类,就又会发生装箱操作,在这个过程中就会隐藏地浪费大量的空间,而自己却什么也不知道。

关于对象空间的大小,请参看第3章的内容。

□ 横向扩展

通过对Integer的一些了解,想到了Boolean、Byte、Short、Long、Float、Double,它们是否有同样的情况,胖哥不想写重复的东西,直接给出结果,大家可以自己去看看代码,看看这些类型中的valueOf()方法是如何操作的,或者说自动装箱是如何操作的。

◎ Boolean的两个值true和false都是cache在内存中的,无须做任何改造,自己new Boolean是另外一块空间。

◎ Byte的256个值全部cache在内存中,和自己new Byte操作得到的对象不是一块空间。

◎ Short、Long两种类型的cache范围为-128~127,无法调整最大尺寸,即没有设置,代码中完全写死,如果要扩展,需自己来做。

◎ Float、Double没有cache,要在实际场景中cache需自己操作,例如,在做图纸尺寸时可以将每种常见尺寸记录在内存中。

□ 思维发散扩展

如果上面的操作变成Integer与int类型比较会是什么样的结果呢?如果是两个Integer数据做“>”、“>=”、“<”、“<=”比较,做switch case操作,会得到什么结果?反射的时候是否有特殊性?

这个结果大家可以去论证,且测试结果可以就认为是当前虚拟机的设计规范。下面胖哥直接给出结果。

◎ 当Integer与int类型进行比较的时候,会将Integer转化为int类型来比较(也就是通过调用intValue()方法返回数字),直接比较数字,在这种情况下是不会出现例子中的问题的。

◎ Integer做“>”、“>=”、“<”、“<=”比较的时候,Integer会自动拆箱,就是比较它们的数字值。

◎ switch case为选择语句,匹配的时候不会用equals(),而是直接用“==”。而在switch case语句中,语法层面case部分是不能写Integer对象的,只能是普通的数字,如果是普通的数字就会将传入的Integer自动拆箱,所以它也不会出现例子中的情况。

在JDK 1.7中,支持对String对象的switch case操作,这其实是语法糖,在编译后的字节码中依然是if else语句,并且是通过equals()实现的。

◎ 在反射当中,对于Integer属性不能使用field.setInt()和field.getInt()操作。在本书的src/chapter01/AutoBoxReflect.java中用例子来说明。

†† 1.5.2 集合类

如果读了上一节后你有所体悟,那么胖哥认为你可以跳过此节,因为此节知识为上一节的一个平行扩展,我们不重视知识点本身,而在于让大家了解到许多秘密。

集合类非常多,从早期的java.utils的普通集合类,到现在增加的java.util.concurrent包下面的许多并发集合类,其实我们有些时候只是知道它们是很好用的东西,但在遇到某些问题的时候是否会想到是它们造成的(就像String一样),它们的使用技巧有哪些?它们的设计思想是什么?

本节不讨论并发包,就简单说说集合类的故事。

疑惑:集合类中包含了List、Map、Set几大类基本接口,而我们最常用、最简单的集合类是什么呢?

答曰:ArrayList、HashMap。

那么,当你用ArrayList的时候是否想起了LinkedList、Vector;当你用HashMap的时候是否想起了TreeMap、HashSet、HashTable;当你要排序的时候是否想起了SortedSet等。

它们有何区别?在什么情况下使用?

在讨论String后面的部分内容中,我们提到了StringBuilder,它内在的数组的实现有大量的拷贝,这在集合类的内存拷贝方面的体现更加明显,并且占用空间更大。

占用更大空间的原因是集合类都是存储对象的引用的,在32bit及64bit压缩模式下,一个引用会占用4个字节,在64bit非压缩模式下会占用8个字节,而StringBuilder只是存储char字符的数组,每个位只占用2个字节。

此时以ArrayList为例,我们看看它的add(E e)方法源码,如图1-9所示。

SUINFCXN7`QR[L]IB9W})ZD

图1-9 ArrayList的add(E e)方法源码截图

通过源码我们发现,如果空间不够,会通过Arrays.copyOf创建一个新的内存空间,新空间的大小最小为原始空间的3/2 倍+1,并将原始空间的内容拷贝进去。

这里所提到的新空间的大小为原始空间的3/2倍+1,是最小的,在add(E)方法中不会发生,而在addAll()方法中会发生。addAll()允许同时写入多个数据,如果写入的数据较多,每次按照1.5倍数扩容,可能发生多次扩容,这样就会有多余的垃圾空间产生,addAll()操作就会对比写入的量与1.5倍的大小,谁大就用谁,这个道理在StringBuilder中我们就知道,因此文中提到的是“最小”。

我们知道,ArrayList是基于“数组”来实现的(本书3.5节会详细介绍内存结构),如果遇到remove()操作,add(int index)指定的位置写入操作,我们有没有虑过ArrayList内部其实会移动相关的数据,而且随着数组越长,移动的数据会越多。如果要替换一个数据,我们会不会先remove再add一个数据,或者是通过set(int index , E e)将对应下标的数据替换掉。

基于数组的ArrayList是非常适合于基于下标访问的,这是它擅长的地方(又回到基本的数据结构与算法了)。下面胖哥给出几个简单扩展,希望大家去思考。

◎ 在经常做修改操作的列表中,或者在数组通过下标检索并不是那么多的情况下,你是否考虑过使用LinkedList呢?因为ArrayList通常始终有些数组元素是空着的。

◎ 在知道List长度范围的情况下,你是否在实例化 ArrayList的时候带上长度?例如new ArrayList(128); 这样就降低了内存碎片和内存拷贝的次数。

◎ 当List太大的时候是否考虑过对它做分段处理,而不要一次加载到内存中?其实很多OOM都会在集合类中找到问题。

◎ 常见的框架中用了什么集合类?在什么情况下也会出现问题?

大家熟知的HashMap浪费空间更加严重,它的代码里面有一个0.75因子,当写入HashMap的数据个数(不是说所使用的数组下标个数,而是所有元素个数,也就是说,包含了同一个下标的链表中的所有元素个数)达到数组长度的0.75后,数组会自动扩展1倍,并且还需要做一个rehash操作,其实这个时候也许很多桶上的节点都是空的。

胖哥不想扯太多的集合类出来,把读者“读晕”,大家在理解这两个基本的集合类基础上,再去看其他的集合类也许会简单一点。胖哥只想让你知道这些东西是可选择的,在什么时候去选择,如何去选择完全要看你自己的功底,不论是做基础程序、做功底还是去做优化,都需要深知它的细节,才能做到心中有数

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

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

相关推荐

发表回复

登录后才能评论