前一段写了一篇《认识JVM》,不过在一些方面可以继续阐述的,在这里继续探讨一下,本文重点在于在heap区域内部对象之间的组织关系,以及各种粒度之间的关系,以及JVM常见优化方法,文章目录如下所示:
1、回顾--java基础的对象大概有哪些特征
2、上一节中提到的Class加载是如何加载的
3、一个对象放在内存中的是如何存放的
4、调用的指令分析
5、对象宽度对其问题及空间浪费
6、指令优化
正文如下:
1、回顾--java基础的对象大概有哪些特征?
相信学习过java或者叫做面向对象的人至少能说出面向对象的三大特征:封装、继承、多态,在这里我们从另一个角度来看待问题,也就是从设计语言的角度来说,要设计一门类似于java的语言,它需要的特征是什么?
->首先所有的内容都应该当基于“类”来完成。
->单继承特征,并且为单根继承(所有类的顶层父类都是java.lang.Object)
->重载(OverLoad)、重写(Overriding)
->每个区域可以划分为对象类型、原生态的变量、方法,他们都可以加上各种作用域、静态等修饰词等。
->支持内部类和内部静态类。
->支持反射模型。
通过上面,我们知道了java的对象都是由一个class的描述来完成的(其实class本身也是由一个数据结构的描述,只不过用它来描述对象的形状和模型,所以我们暂时理解class就是一个描述,不然这里一层一层向下想最终可能什么都想不出来或者可能想到的是汇编语言,呵呵,站在这一层要研究它就将下一层当成底层,原理上的支撑都是一样的道理),那么最关键就是如何通过构造出一个class的样子出来,此处我们不讨论关于javaCC方面的话题,也就是编译器的编译器问题,就单纯如何构建这种模型而探讨;在jvm的规范中明确说明了一点:java不强制规定用某种格式来限定对象的存储方法。也就是说,无论你怎么存储,只要你能将类的意义表达出来,并可以相互关联即可。
在语言构建语言的基础上,很多时候都是通过底层语言去编写高级语言的编译器或解释器,编写任何一门语言的基础都离不开这门语言的对象存储模型,也就是对象存储方式;如java,标准的SUN(现在是ORACLE),它是通过C++编写的,而BEA的jdk是纯C语言编写的,IBM的jdk有一部分是C,一部分是C++语言。
你也可以实现一个类似于java的对象模型,比如你用java再去编写一门更加高级的语言(如:Groovy),你要构建这门语言的对象模型就是这样一个思路,至于javaCC只是将其翻译为对应运行程序可以识别模型来表达出来而已,就像常规的应用设计中,要设计业务的实现,就要先设计业务的模型,也就是数据结构了;语言也是这样,没有结构什么也谈及不上,数据结构也就是在最基本、最底层的架构层面,脱离出一些逻辑结构,来达到某些编程方面的目的,如有些是为了高效、有些是为了清晰编码。
在内存中本身是不存在类这种概念的,甚至于可以说连C的结构体也是不存在的,内存中最基本的对象只有两种:一个是链表、一个是数组,所有其他的模型都是基于这些模型逻辑构建出来的,那么当要去构建一个java的对象的时候,根据上面的描述你应当如何去构建呢?下一章就事论事的方式来讨论一下。
2、上一篇文章中的class是如何加载和对象如何绑定的
在上一篇文章中已经提及到了class初始化加载到内存中的结构以及动态装载的基本说明;而在装载的过程中java本身有一个不成文的规定,那就是在默认情况下或者说一般情况下Perm区域的Class类的区域,是不会被修改的,也就是说一个class类在被装入内存后,它的地址是不会再被修改的,除非用一些特殊的API去完成,常规的应用中,我们也没有必要的说明这个问题,也不推荐去使用这个东西;在后面的运行时优化中会提到jvm会利用这个特征去做一些优化,如果失去这个特征就会失去一些优化的途径。
那么如果要组织一个对象,对象首先肯定需要划分代码段、数据段,代码段需要和数据段进行绑定;
首先我们用最基本、最简单的方法来做:就是当我们发起一个new XXX();时,此时将代码段从Perm中拷贝一份给对象所处的空间,首先我们的代码段应该写入的内容就是:属性列表、方法列表,而每一个列表中通过名称要找到对应的实体,通过最底层的数据结构是什么呢?最简单的就是我们定义一个数组,存放所有的目标的数据地址位置,而名称用另一个数组,此时遍历前一个数组逐个匹配,找到下标,通过相同下标,找到实际数据的地址。你看到这里是不是有一些疑惑,这样的思路就像小学生一样,太烂了,不过JVM还真经历过这个过程,我们先把基本的思路提出来,接下来再来看如何优化和重构模型。
通过上面的简单思路不难发现了两个问题:一个问题就是相同的对象经常被反复的构造,我们先不知道代码段的大小,先抛开这个问题,后面看看如果在内存中要构造一个代码段应该如何构造再看代码段的大小;另一个问题是你会发现这样是不是很慢,而且在对象的名称与地址之间,这个二元组上就很像我们所谓的K-V格式,那么Hash表呢,Hash表不正是表达K-V格式的吗,而且匹配效率要高出很多(其实Hash表也是通过数学算法加上数组和链表来实现的),只是又从逻辑上抽象了一下而已;而不论是通过什么数据结构来完成,它始终有一个名称对应值的结构,只要能实现它,java不在乎于你使用什么结构来实现(JVM规范中说明,只要你能表达出java的对象内部的描述信息,以及调用关系,你就是符合JVM规范的,它并不在乎于你是用什么具体的数据结构来表达),那么一起来看看如果你要构建一个java的语言的对象模型应当如何构建呢?
综上,我们要首先定义一个java的基本类,我们首先在逻辑上假设,要在代码段内部知道的事情是:
HashMap<String,Class<? extends Object>> classes;
由此可以通过名称定位到代码段的空间。
而在一个Class内部要找到对应的属性,我们也需要定义它们的关系:
Field []params;//表示该列的参数列表
Method[]methods;//表示该类的方法列表
Class []innerClass;//该类的内部类和静态内部类列表
Class parentClass;//该类的直接父亲类引用
Map<String , Object>;//用于存放hash表
其实代码段只是由相对底层的语言,构造的另一种结构,也就是它本身也是数据结构,只是从另一个角度来展现,所以你在理解class的定义的时候,你本身就可以将其理解为一个对象,存储在Perm中的一个对象;
上面为一种伪语言的描述,因为java不要求你用什么去实现,只要能描述它的结构就可以,所以这种描述有很多的版本,至于这个不想做过多的追究,继续深入的就是通过发现,要构造一个class的定义,是不容易的,它也会开销不小的内存;如果像上面我们说的,你定义一个对象,就把class拷贝过来,也就是上面说到存储在Perm定义部分的对象,那么这个空间浪费将会成倍数上涨,所以我们想到的下一个办法就是在利用JVM的class初始化后,它的地址就不会发生变化(上面说了,除非用特殊的API,否则不会发生变化),那么我们在定义对象的时候,就用一个变量指向这个class的首地址就可以了,这样对象的定义部分就只有一份公共的存储了,类似静态常量等JVM也采用相同的手段,抽象出来存储一份,这样来节约空间的目的。
好了,空间是节约下来了,接下来,当要对对象加锁synchronize的时候(这里也不讨论纯java实现的Lock实现类和Atomic相关包装类),加在哪里,当要对所有的同类对象加锁的时候加在哪里?它就是加在对象的头部,前面说了,class的定义也可以当成一个已经被初始化好的对象,所以锁就是可以在两个粒度的头部上去加锁了,当代码运行到要加锁头部的时候,就会去找这个对应的位置是否已经被加锁,如果已经被加锁,会处于一个等待池中,根据优先级然后被调用(顺便提及一下,java对线程优先级是10个级别(1-10),默认是5,但是操作系统未必支持,所以有些时候优先级太细在多数操作系统上是没有作用的,很多时候就设置为最大、最小或者不设置)。
顺便补充一下,在上一节中已经提到,关于对象头部,在早期的JVM中,对象是没有所谓的头部的,这部分内容在JVM的一块独立区域中(也就是有一块独立的handle区域,也就是一个引用首先是经过handle才会找到对象,java在对象引用之间关系比较长,这样会导致非常长的引用问题,另外在GC的算法上也会更加复杂,并且扩展空间时,handle和对象本身是在两块不同的空间,但是由于都需要扩展空间,可能会导致更多的问题出现;最后它将会在申请空间时由于处理的复杂性多使用更多的CPU指令,现在的JVM每个new的开销大概是10个CPU指令,效率上可以和C、C++媲美),不过后来发现这样的设计存在很多的问题,所以现在的jvm所有的都是有头部的问题,至于头部是什么在第五章中一起探讨一下。
上面探讨了一下关于Class定义的问题,假设出来是什么样的了,如果你要构造一个对象的基本描述,应该如何描述呢?下一章来详细说明一下。
3、一个对象在内存中是如何存放的?
有关一个对象在对象中如何移动以及申请在上一篇文章中已经描述,目前我们模拟一下,如果你要设计一个对象在内存中如何存放应当如何呢?
在上面说明了Class有定义部分,用独立的位置来存放,对象用一个指针指向它,在这里我们先只考虑变量的问题,那么有两种思路去存放,一种就是采用一个HashMap<String,? exntends Object>去存放对象的名称、和对象的的值,但是你发现这样又把代码段的名称拷贝过来了,我们不想这样做,那么就定义一个和代码段中数组等长的Object数组,即Object []obj = new Object[params.length];当然需要一个指向代码段Class的地址向量,此时我们用一个:Class clazz来代表,其实当你用对象.class就是获取这个地址,此时当需要获取某个对象的值的时候,通过这个地址,找到对应的Class定义部分,在Class定义内部做一个Hash处理,找到对应的下标,然后再返回回来找到我们对应变量的对应下标,此时再获取到对应的值。
问题是可以这样解决,是不是感觉很绕,其实不论找到一个变量还是一个方法去执行的时候,都要通过Class的统一入口地址进去,然后通过遍历或者Hash找到对应的下标位置或者说实际的地址,然后去调用对应的指令才开始执行;那么这样做我们感觉很绕,有没有什么方法来优化它呢,因为这样java肯定会很慢,答案是肯定的,只要有结构肯定就有办法优化,在下面说明了指令以及对象空间宽度问题后,在最后一章说明他有哪些优化方案。
貌似第三章就这么简单,也没有什么内容,java这个对象这么简单就被描述了?是的,往往越简单的对象可以解决越加复杂的内容,复杂的问题只有简单化才能解决问题,不过要深入探讨肯定还是有很多话题的,如:继承、实现等方法,在java中,要求继承中子类能够包含父亲类的protected、public的所有属性和方法,并且可以重写方法,在属性上,java在实例化时,是完全可以在Class定义部分就完成的,因为在Class定义部分就可以完全将父类的相应的内容包含进来(不过它会标记出那些是父类的东西,那些是当前类的东西,这样在this、super等多个重写方法调用上可以分辨出来),避免运行时去递归的过程,而在实例化时,由于相应的Class中有这些标记,那么就可以非常轻松的实现这些定义了,而在构造方法上,它通过子类构造方法入口,默认调用父亲类,逐层向上再反向回来即可。
那么目前看到这里,可能比较关心的问题就是方法是如何调用的?对象头部到底是什么?调用的优化是如何做的?继承关系的调用是怎么回事,好吧,我们下面来讨论下如何做这些事情:
4、调用的指令分析:
要明白调用的指令,那么首先要看看JVM为我们提供了哪些指令,在jdk 1.6已经提供的主要方法调用指令有:
invokestatic、invokespecial、invokevirtual、invokeinterface,在jdk 1.7的时候,提出了一条invokedynamic的指令,用以应付在以前版本的jdk中对动态代码调用的性能问题,jdk 1.7以后用专门的指令要解决这一问题,至于怎么回事,我也不清楚,只是看文档是这样的,呵呵;下面简单介绍下前面几个指令大概怎么回事(先说指令是什么意思,后面再说怎么优化的)。
invokestatic一看就知道是调用静态代码段的,当你定义个static方法的时候,外部调用肯定是通过类名.静态方法名调用,那么运行时就会被解释为invokestatic的JVM指令;由于静态类型是非常特殊的,所以编译时我们就完全独立的确立它的位置,所以它的调用是无需再被通过一连串的跳转找到的。
invokespecial这个是由JVM内部的一个父类调用的指令,也就是但我们发生一个super()或super.xxx()时或super.super.xxx()等,就会调用这个指令。
invokevirtual由jvm提供的最基本的方法调用命令,也就是直接通过 对象.xxx() 来调用的指令。
invokeinterface当然就是接口调用啦,也就是通过一个interface的引用,指向一个实现类的实例,并通过调用interface的类的对应方法名,用以找到实现类的实际方法。
这里的指令在第一次运行时都需要去找到一个所谓的入口调用点,也成为call side,最基本的就是通过名称,找到对应的java的class的入口,找到一个非动态调用的方法以及其多个版本号,根据实际的指令调用的对应的方法,编译为指令运行。
明白了这些指令我们比较疑惑的就是在继承与接口的方法调用上如何做到快速,因为一门语言如果本身就很慢的话,外部要调优也是无济于事的,于是在找到多个实现类的时候,我们一般提出以下几种查找到底要调用哪一个方法的假设,每一种假设他们都有一个性能的标准值。
当存在多层的继承时,并存在着重写等情况的时候,要考虑到实际调用的方法的时候,我们做以下几种假设:
1、假如在初始化类中,将父类的相应的方法也包含进来,只是做相应的标识码,并且按照数组存放,此时,就会存在同名方法,做hash的话就有些困难了,当然你可以带上标识符做hash,但是hash的KEY是唯一的,此时需要的不仅仅是自己的方法调用,还需要一连串的,不过可以按照制定的规则逐个查找。
2、另一种是不包含进来自下而上递归查找,也是较为原始的方法,虽然效率上有点低,不过大部分集成关系不会超过3层以上。
3、在这个角度,另一种方法是基于方法名的地址做纵向向量,也就是在自下向上的查找中,只需要定位最后一个入口地址,直接调用便直接使用,当使用super的时候,就按照数组进行反向偏移量,这貌似是一个不错的方法,不过查找呢,我们将这个数组做为一个整体的Value,来让Hash找到,每个方法标识这自己来源于哪一个类,以及,由类关联出他们的子孙关系即可。也就是说,在一般情况下,jvm认为继承关系不是太长的,或者说是即使继承关系很长,在继承的关系链表中,自上而下任意找一条链上上去,重写的方法个数并不是很多,一般最多保持在3、4个左右,所以在直接记录层次上,是完全可行的;但是问题是,这种层次分析不允许在对象内部发生任何的层次变化,也就是纯静态的,但是java本身是支持动态Load的,所以静态编译器无法完成这个操作,而动态编译器可以,在变化的过程中需要知道退路。
其实这部分有点类似于调用优化了,不过后面还会说明更多的调用优化内容,因为从上述的阅读中你应该会发现,一个操作后的调用会导致非常多的寻址,而且很多是没有必要的,我们在最后一章配合一些简单例子再来说明(例子中会说到上述的一些指令的调用),下一章先说明下对象在内存中到底是如何存储和浪费空间的。
5、对象宽度及空间浪费
对象宽度这个说法很多时候都是C语言、C++这些底层语言中经常讨论的话题,而通过这些语言转变过来的人大多数对java比较反感的就是为什么没有一个sizeof的函数来让我知道这个对象占用了多大的内存空间;java其实即使让你知道大小也是不准确的,因为它中间有很多的对齐和中间开销,如在多维数组中,java的前面几个维度都是浪费的空间,只有最后一个维度的数据,也就是N多个一维数组才是真正的空间大小,而且它中间存在很多对象的对象等等概念。
那么一个简单对象,java的对象到底是如何存放的呢?首先明白一点,Hotspot的JVM中,java的所有对象要求都是按照8个byte对齐的,不论任何操作系统都是这样,主要是为了在寻址时的偏移量比较方便。
然后,对象内部各个变量按照类型,如果对象是按照类型long/double占用8个byte、int/float占用4个byte,而short/char是占用2个byte,byte当然是占用一个了,boolean要看情况,一般也是一个byte,而对象内部的指向其他对象的引用呢?这个也是需要占用空间的,这个空间和OS和JVM的地址位数有关系,当然OS为32位时,这个就占用4个byte,当OS为64位数时,就占用8个byte,在根引用中,操作系统的stack指向的那个引用大小也是这样,不过这里是对象内部指向目标对象的宽度。
对象内部的每个定义的变量是否按照顺序存储进去呢?可以是也可以不是(上面已经说了,JVM并不强制规定你在内存中是如何存放的,只需要表达出具体的描述),但是一般不是,因为当采用这种方式的时候,当再内部定义的变量由于顺序的问题,导致空间的浪费,比如在一个32位的OS中定义个byte,再定义一个int,再定义一个char,如果按照顺序来存储,byte占用一个字节,而int是4个字节,在一个内存单元下,下面只剩下3个byte,放不下了,所以只有另外找一个内存单元存放下来,接下来的char也不得不单独在用一块4byte的内存单元来存放,这样导致空间浪费(不过这样寻址是最快的,因为按照OS的位数进行,是因为这是寻址的基本单位,也就是一个CPU指令发出某个地址寻址时,是按照地址带宽为基本单位进行寻址的,而并非直接按照某个byte,如果将两个变量放在同一个地址单元,那么就会产生2次偏移量才能找到真正的数据,有关逻辑地址、线性地址、物理地址上的区别在上一篇文章说有概要的介绍);
不过在java默认的类中一般是按照顺序的(比如java的一个java.lang.String这些类内存的顺序都是按照定义变量的顺序的),虚拟机知道这些类,相当于一些硬代码或者说硬配置项,这也是虚拟机要认名字的一特征就像实例化序列化接口一样,其实什么都不用写只是告诉虚拟机而已;由于这些类在很多运行时虚拟机知道这些是自己的类,所以他们在内存上面会做一些特殊的优化方案,而和外部的不是一样的。
在Hotspot的JVM对参数FieldsAllocationStyle可以设置为0、1、2三种模式,默认情况下参数模式1,当采用0的时候:采用的是先将对象的引用放进去(记住,String或者数组,都是存放的引用地址),然后其他的基本变量类型的顺序为从大到小的顺序,这样就大量避免了空间开销;采用模式1的时候,也就是默认格式的时候,和0格式的唯一区别就是将对象引用放在了最后,其实没什么多大的区别;当采用模式2的时候,就会将继承关系的实例化类中父子关系的变量按照顺序进行0、1两种模式的交叉存放;而另一个参数CompactFields则是在分配变量时尝试将变量分配到前面地址单元的空隙中,设置为true或者false,默认是true。
那么一个对象分配出来到底有哪些内容呢,那我们来分析下一个对象除了正常的数据部分以及指向代码段的部分,一般需要存放些什么吧:
1、唯一标识码,每一个对象都应该有一个这样的编码,唯一hash码。
2、在标记清除时,需要标记出这个对象是否可以被GC,此时标记就应该标记在对象的头部,所以这里需要一个标识码。
3、在前一篇文章中说明,在Young区域的GC次数,那么就要记录下来需要多少次GC,那么这个也需要记录下来。
4、同步的标识,当发生synchronized的时候,需要将对象的头部记录下对象已经被同步,同时需要记录下同步该对象的线程ID。
5、描述自身对象的个数等内容的一个地方。等等。。也许还有很多,不过我们至少有这么一些内容。
不过这些内容是不是每个时候都需要呢,也就是对象申请就需要呢?其实不然,如线程同步的ID我们只需要在同步的时候在某个外部位置存放就可以了,因为我们可以认为线程同步一般是不会经常发生的,经常发生线程同步的系统也肯定性能不好,所以可以用一个单独的地方存放。
前面1-4,很多时候我们把这个区域叫做:_mark区域,而第五个地方很多时候叫做:_kclass区域。加在一起叫做对象的头部(这个头部一般是占用8个byte的空间,其中_mark和_kclass各自占用4个byte)。
现在明白了对象的头部了,那么对象除了头部以外,还有其他的空间开销吗?那就是前面提到Hotspot的java的对象都是按照8个byte的偏移量,也就是对象的宽度必须是8byte的整数倍,当对象的宽度不是8的整数倍数的时候,就会采用一些对其方式了,由于头部本身是8个byte,所以大家写程序可以注意一点,当你使用数据的空间byte为8的整数倍,这个对其空间就会被节约出来。
随着上面的说明,对其和头部,我们来看看几个基本变量和外包类的区别,Byte与byte、Integer与int、String a = "b";
首先byte只占用一个byte,当使用Byte为一个对象时,对象头部为8个字节,数据本身占用1个byte,对其宽度需要7个byte,那么对象本身的开销将需要16个byte,此时,也就是说两者的空间开销是16倍的差距,你的空间利用率此只有6.25%,非常小;而int与Integer算下来是25%,String a = "b"的利用率是12.5%(其实String内部还有数组引用的开销、数组长度记录、数组offset记录、hash值的开销、以及序列化编码的开销,这里都没有计算进去, 这部分开销如果要计算进去,利用率就低得不好描述了,呵呵,当然如果数组长度长一点利用率会提高的,但是很多时候我们的数组并不是很长),呵呵,说起来蛮吓人的,其实空间利用还是靠个人,并不是说大家以后就不敢用对象了,关键是灵活应用,在jdk 1.5以后所谓的自动拆装箱,只是JVM帮你完成了相互之间的转换,中间的空间开销是免不掉的,只是如果你的系统对空间存储要求还是比较高的话,在能够使用原生态类型的情况下,用原生态的类型空间开销将会小很多。
补充说明一下,C、C++中的对象,直接在结构体得typedef后面定义的默认接的那个对象,是没有头部的,纯定义类型,当然C++中也有一个按照高位宽度对其的说法,并且和OS的地址宽度有关系,通过sizeof可以做下测试;但是通过指针=malloc等方式获取出来的堆对象仍然是有一个头部的,用于存放一些metadata内容,如对象的长度之类的。
好了。看到了指令,看到对象如何存储,迫不及待的想要看看如何去优化的了,那么我们看看虚拟机一般会对指令做哪些优化吧。
6、指令优化:
在谈到优化之前我们先看一个简单例子,非常简单的例子,查看编译后的文件的的指令是什么样子的,一个非常简单的java程序,Hello.java
public class Hello {
public String getName() {
return "a";
}
public static void main(String []args) {
new Hello().getName();
}
}
我们看看这段代码编译后指令会形成什么样子:
C:\>javac Hello.java
C:\>javap -verbose -private Hello
Compiled from "Hello.java"
public class Hello extends java.lang.Object
SourceFile: "Hello.java"
minor version: 0
major version: 50
Constant pool:
const #1 = Method #6.#17; // java/lang/Object."<init>":()V
const #2 = String #18; // a
const #3 = class #19; // Hello
const #4 = Method #3.#17; // Hello."<init>":()V
const #5 = Method #3.#20; // Hello.getName:()Ljava/lang/Stri
const #6 = class #21; // java/lang/Object
const #7 = Asciz <init>;
const #8 = Asciz ()V;
const #9 = Asciz Code;
const #10 = Asciz LineNumberTable;
const #11 = Asciz getName;
const #12 = Asciz ()Ljava/lang/String;;
const #13 = Asciz main;
const #14 = Asciz ([Ljava/lang/String;)V;
const #15 = Asciz SourceFile;
const #16 = Asciz Hello.java;
const #17 = NameAndType #7:#8;// "<init>":()V
const #18 = Asciz a;
const #19 = Asciz Hello;
const #20 = NameAndType #11:#12;// getName:()Ljava/lang/String;
const #21 = Asciz java/lang/Object;
{
public Hello();
Code:
Stack=1, Locals=1, Args_size=1
0: aload_0
1: invokespecial #1; //Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 11: 0
public java.lang.String getName();
Code:
Stack=1, Locals=1, Args_size=1
0: ldc #2; //String a
2: areturn
LineNumberTable:
line 14: 0
public static void main(java.lang.String[]);
Code:
Stack=2, Locals=1, Args_size=1
0: new #3; //class Hello
3: dup
4: invokespecial #4; //Method "<init>":()V
7: invokevirtual #5; //Method getName:()Ljava/lang/String;
10: pop
11: return
LineNumberTable:
line 26: 0
line 30: 11
}
看起来乱七八糟,不要着急,这是一个最简单的java程序,我们按照正常的程序思路从main方法开始看,首先第一行是告诉你new #3;//class Hello,这个地方相当于执行了new Hello()这个命令,而#3是什么意思呢,在前面编译的指令列表中,找到对应的#3的位置,这就是我们所谓的入口位置,如果指令还要去寻找下一个指令就跟着#找到就可以了,就想刚才#3又找到#19,其实是要找到Hello的定义,也就是要引用到Class的定义的位置。
继续看下一步(关于内部入栈出栈的指令我们这里不多说明),invokespecial #4; //Method "<init>":()V,这个貌似看不太懂,不过可以看到后面是一个init方法,它到底初始化了什么,我们这里因为只有一行代码,我们姑且相信它初始化了Hello,不过invokespecial不是对super进行调用的时候才用到的吗?所以这里需要补充一下的就是当对象的初始化的时候,也会调用它,这里的初始化方法就是构造方法了,在指令的时候统一命名为init的说法;
那么调用它的构造方法,如果没有构造方法,肯定会进入Hello的默认构造方法,我们看看上面的public Hello(),发现它内部就执行了一条指令就是调用又调用一个invokespecial指令,这个指令其实就是初始化Object父对象的。
再继续看下一条指令:invokevirtual #5; //Method getName:()Ljava/lang/String;你会发现是调用了getName的方法,采用的就是我们原先说的invokevirtual的指令,那么根据到getName方法部分去:
会发现直接做了一个ldc #2; //String a操作就返回了,获取到对应的数据的地址后就直接返回了,执行的指令在位置#2,也就是在常量池中的一个2。
好了一个简单的程序指令就分析到这里了,更多的指令大家可以自己去分析,你就可以看明白java在指令上是如何处理的了,甚至于可以看出java在继承、内部类、静态内部类的包含关系是如何实现的了,它并不是没用,当你想成为一个更为专业和优秀的程序员,你应该知道这些,才能让你对这门驾驭得更加自如。
几个简单的测试下来,会发现一些常见的东西,比如
==>你继承一个类,那个类里面有一个public方法,在编译后,你会发现这个父亲类的方法的指令部分会被拷贝到子类中的最后面来
==>而当使用String做 “+” 的时候,那怕是多个 "+" ,JVM会自动编译指令时编译为StringBuilder的append的操作(JDK 1.5以前是StringBuffer),大家都知道append的操作将比 + 操作快非常的倍数,既然JVM做了这个指令转换,那么为什么还这么慢呢,当你发现java代码中的每一行做完这种+操作的时候,StringBuilder将会做一个toString()操作,如果下一次再运行就要申请一个新的StringBuilder,它的空间浪费在于toString和反复的空间申请;并且我们在前面探讨过,在默认情况下这个空间数组的大小是10,当超过这个大小时,将会申请一个双倍的空间来存放,并进行一次数组内容的拷贝,此时又存在一个内部空间转换的问题,就导致更多的问题,所以在单个String的加法操作中而且字符串不是太长的情况下,使用+是没有问题的,性能上也无所谓;当你采用很多循环、或者多条语句中字符串进行加法操作时,你就要注意了,比如读取文件这类;比如采用String a = "dd" + "bb" + "aa";它在运行时的效率将会等价于StringBuilder buf = new StringBuilder().append("dd").append("bb").append("aa");
但是当发生以下情况的时候就不等价了(也就是不要在所有情况下寄希望于JVM为你优化所有的代码,因为代码具有很多不确定因素,JVM只是去优化一些常见的情况):
1、字符串总和长度超过默认10个字符长度(一般不是太长也看不出区别,因为本身也不慢)。
2、多次调用如上面的语句修改为String a = "dd";a += "bb"; a += "aa";与上面的那条语句的执行效率和空间开销都是完全不一样的,尤其是很多的时候。
3、循环,其实循环的基础就是来源于第二点的多次调用加法,当循环时肯定是多次调用这条语句;因为Java不知道你下一条语句要做什么,所以加法操作,它不得不将它toString返回给你。
==>继续测试你会发现内部类、静态内部类的一些特征,其实是将他编辑成为一个外部的class文件,用了一些$标志符号来分隔,并且你会发现内部类编译后的指令会将外包类的内容包含进来,只是他们通过一些标志符号来标志出它是内部类,它是那个类的内部类,而它是静态的还是静态的特征,用以在运行时如何来完成调用。
==>另外通过一些测试你还会发现java在编译时就优化的一个动作,当你的常量在编译时可能会在一些判定语句中直接被解析完成,比如一个boolean类型常量IS_PROD_SYS(表示是否为生产环境),如果这个常量如果是false,在一段代码中如果出现了代码片段:
if(IS_PROD_SYS) {
.....
}
此时JVM编译器在运行时将会直接放弃这段代码,认为这段代码是没有意义的;反之,当你的值为true的时候,编译器会认为这个判定语句是无效的,编译后的代码,将会直接抛弃掉if语句,而直接运行内部的代码;这个大家在编译后的class文件通过反编译工具也可以看得出来的;其实java在运行时还做了很多的动作,下面再说说一些简单的优化,不过很多细节还是需要在工作中去发现,或者参考一些JVM规范的说明来完善知识。
上面虽然说明了很多测试结果所表明的JVM所为程序所做的优化,但是实际的情况却远远不止如此,本文也无法完全诠释JVM的真谛,而只是一个开头,其余的希望各位自己可以做相应的测试操作;
说完了一些常见的指令如何查看,以及通过查看指令得到一些结论,我们现在来看下指令在调用时候一般的优化方法一般有哪些(这里主要是在跨方法调用上,大家都知道,java方法建议很小,而且来回层次调用非常多,但是java依然推荐这样写,由上面的分析不得不说明的是,这样写了后,java来回调用会经过非常的class寻址以及在class对对内部的方法名称进行符号查表操作,虽然Hash算法可以让我们的查表提速非常的倍数,但是毕竟还是需要查表的,这些不变化的东西,我们不愿意让他反复的去做,因为作为底层程序,这样的开销是伤不起的,JVM也不会那么傻,我们来看看它到底做了什么):
==>在上面都看到,要去调用一个方法的call site,是非常麻烦的事情,虽然说static的是可以直接定位的,但是我们很多方法都不是,都是需要找到class的入口(虽然说Class的转换只需要一次,但是内部的方法调用并不是),然后查表定位,如果每个请求都是这样,就太麻烦了,我们想让内部的放入入口地址也只有一次,怎么弄呢?
==>在前面我们说了,JVM在加载后,一般不使用特殊的API,是不会造成Class的变化的,那么它在计算偏移量的时候,就可以在指令执行的过程中,将目标指令记忆,也就是在当前方法第一次翻译为指令时,在查找到目标方法的调用点后,我们希望在指令的后面记录下调用点的位置,下次多个请求调用到这个位置时,就不用再去寻找一次代码段了,而直接可以调用到目标地址的指令。
==>通过上面的优化我们貌似已经满足了自己的想法,不过很多时候我们愿意将性能进行到底,也就是在C++中有一种传说中的内联,inline,所以JVM在运行时优化中,如果发现目标方法的方法指令非常小的情况下,它会将目标方法的指令直接拷贝到自己的指令后面,而不需要再通过一次寻址时间,而是直接向下运行,所以JVM很多时候我们推荐使用小方法,这样对代码很清晰,对性能也不错,大的方法JVM是拒绝内联的(在C++中,这种内联需要自己去指定,而并非由系统完成,正常C++的指令也是按照入口+偏移量来找到的)
==>而对于继承关系的优化,通过层次模型的分析,我们在第四章中就已经说明,也就是利用一般情况下多态中的单个链中对应的对象的重写方法数组肯定不会太长,所以在Class的定义时我们就知道自下向上有多少个重写方法,而不是运行时才知道的,这个也叫做编译时的层次分析。
==>从上面方法的应用上,我们在适当的条件下如何去编写代码,适当的条件下去选择单例和工厂、适当的条件下去选择静态和非静态、适当的条件下去选择继承和多态等在通过上面的指令说明后,可以自己做一些简单的实验,就更加清楚啦。
文章写到这里结束,欢迎拍砖!