《深入了解java虚拟机》学习总结

编程入门 行业动态 更新时间:2024-10-11 21:27:59

《深入了解java<a href=https://www.elefans.com/category/jswz/34/1770279.html style=虚拟机》学习总结"/>

《深入了解java虚拟机》学习总结

第一篇:走进Java

一:Java技术体系

从传统意义上来讲,Sun公司所定义的Java技术体系包括:
★Java程序设计语言
★各种硬件平台上的Java虚拟机
★Java API类库
★Class文件格式
★来至商业机构和开源社区的第三方Java类库

我们可以把Java程序设计语言,Java虚拟机,Java API类库这三部分统称为JDK(JAVA Development Kit),JDK是支持Java程序开发的最小环境。另外,我们把Java SE API子集和Java虚拟机这两部分统称为JRE(Java Runtime Environment),JRE是支持程序运行的标准环境。
总结一下JDK和JRE区别就是:如果你需要运行java程序,只需安装JRE就可以了。如果你需要编写java程序,需要安装JDK

如果按照业务领域来划分Java技术系统,可分为下面四种:
★Java Card:支持一些小程序运行在小内存设备(如智能卡)上的平台。
★Java Me(Micro Edition):支持Java运行在移动终端上的平台。
★Java Se(Standard Edition):支持面向桌面级应用的Java平台。
★Java EE(Enterprise Edition):支持使用多层架构的企业应用的Java平台。

二:Java的发展历程

1995年5月23日,Java语言诞生;
1996年1月,第一个JDK-JDK1.0诞生;
1997年2月19日,JDK1.1发布;
1998年12月4日,JDK1.2发布;
2000年5月8日,JDK1.3发布;
2002年2月13日,JDK1.4发布;
2004年9月30日,JDK1.5发布;
2006年12月11日,Java SE 6发布;
2011年7月28日,Java SE 7发布;
2014年3月18日,Java SE 8发布;

三:JVM的发展

1.Sun Classic / Exact VM
1996年1月23日,Sun公司发布JDK 1.0,Java语言首次拥有了商用的正式运行环境,这个JDK1.0中所带的虚拟机就是Classic VM
JDK 1.2时,曾在Solaris平台上发布过一款名为Exact VM的虚拟机,它的执行系统已经具备现代高性能虚拟机的雏形:如两级即时编译器、编译器与解释器混合工作模式等。Exact VM因它使用准确式内存管理(Exact Memory Management,也可以叫Non-Conservative/Accurate Memory Management)而得名,即虚拟机可以知道内存中某个位置的数据具体是什么类型。

2.Sun HotSpot VM
提起HotSpot VM,相信所有Java程序员都知道,它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。但不一定所有人都知道的是,这个目前看起来“血统纯正”的虚拟机在最初并非由Sun公司开发,而是由一家名为“Longview Technologies”的小公司设计的;甚至这个虚拟机最初并非是为Java语言而开发的,它来源于Strongtalk VM,而这款虚拟机中相当多的技术又是来源于一款支持Self语言实现“达到C语言50%以上的执行效率”的目标而设计的虚拟机,Sun公司注意到了这款虚拟机在JIT编译上有许多优秀的理念和实际效果,在1997年收购了Longview Technologies公司,从而获得了HotSpot VM。

3. Sun Mobile-Embedded VM / Meta-Circular VM
Sun公司所研发的虚拟机可不仅有前面介绍的服务器、桌面领域的商用虚拟机,除此之外,Sun公司面对移动和嵌入式市场,也发布过虚拟机产品,另外还有一类虚拟机,在设计之初就没抱有商用的目的,仅仅是用于研究、验证某种技术和观点,又或者是作为一些规范的标准实现。这些虚拟机对于大部分不从事相关领域开发的Java程序员来说可能比较陌生。
4. BEA JRockit / IBM J9 VM
JRockit VM曾经号称“世界上速度最快的Java虚拟机”它是BEA公司在2002年从Appeal Virtual Machines公司收购的虚拟机。BEA公司将其发展为一款专门为服务器硬件和服务器端应用场景高度优化的虚拟机,由于专注于服务器端应用,它可以不太关注程序启动速度,因此JRockit内部不包含解析器实现,全部代码都靠即时编译器编译后执行。除此之外,JRockit的垃圾收集器和MissionControl服务套件等部分的实现,在众多Java虚拟机中也一直处于领先水平。
IBM J9 VM并不是IBM公司唯一的Java虚拟机,不过是目前其主力发展的Java虚拟机。IBM J9 VM原本是内部开发代号,正式名称是“IBM Technology for Java Virtual Machine”,简称IT4J,只是这个名字太拗口了一点,普及程度不如J9。J9 VM最初是由IBM Ottawa实验室一个名为SmallTalk的虚拟机扩展而来的,当时这个虚拟机有一个bug是由8k值定义错误引起的,工程师花了很长时间终于发现并解决了这个错误,此后这个版本的虚拟机就称为K8了,后来扩展出支持Java的虚拟机就被称为J9了。与BEA JRockit专注于服务器端应用不同,IBM J9的市场定位与Sun HotSpot比较接近,它是一款设计上从服务器端到桌面应用再到嵌入式都全面考虑的多用途虚拟机,J9的开发目的是作为IBM公司各种Java产品的执行平台,它的主要市场是和IBM产品(如IBM WebSphere等)搭配以及在IBM AIX和z/OS这些平台上部署Java应用。
5. Azul VM / BEA Liquid VM
我们平时所提及的“高性能Java虚拟机”一般是指HotSpot、JRockit、J9这类在通用平台上运行的商用虚拟机,但其实Azul VM和BEA Liquid VM这类特定硬件平台专有的虚拟机才是“高性能”的武器。
Azul VM是Azul Systems 公司在HotSpot基础上进行大量改进,运行于Azul Systems公司的专有硬件Vega系统上的Java虚拟机,每个Azul VM实例都可以管理至少数十个CPU和数百GB内存的硬件资源,并提供在巨大内存范围内实现可控的GC时间的垃圾收集器、为专有硬件优化的线程调度等优秀特性。在2010年,Azul Systems公司开始从硬件转向软件,发布了自己的Zing JVM,可以在通用x86平台上提供接近于Vega系统的特性。
Liquid VM即是现在的JRockit VE(Virtual Edition),它是BEA公司开发的,可以直接运行在自家Hypervisor系统上的JRockit VM的虚拟化版本,Liquid VM不需要操作系统的支持,或者说它自己本身实现了一个专用操作系统的必要功能,如文件系统、网络支持等。由虚拟机越过通用操作系统直接控制硬件可以获得很多好处,如在线程调度时,不需要再进行内核态/用户态的切换等,这样可以最大限度地发挥硬件的能力,提升Java程序的执行性能。
6. Apache Harmony / Google Android Dalvik VM
Harmony VM和Dalvik VM只能称做“虚拟机”,而不能称做“Java虚拟机”,但是这两款虚拟机(以及所代表的技术体系)对最近几年的Java世界产生了非常大的影响和挑战,甚至有些悲观的评论家认为成熟的Java生态系统有崩溃的可能。
Apache Harmony是一个Apache软件基金会旗下以Apache License协议开源的实际兼容于JDK 1.5和JDK 1.6的Java程序运行平台,这个介绍相当拗口。它包含自己的虚拟机和Java库,用户可以在上面运行Eclipse、Tomcat、Maven等常见的Java程序,但是它没有通过TCK认证,所以我们不得不用那么一长串拗口的语言来介绍它,而不能用一句“Apache的JDK”来说明。如果一个公司要宣布自己的运行平台“兼容于Java语言”,那就必须要通过TCK(Technology Compatibility Kit)的兼容性测试。Apache基金会曾要求Sun公司提供TCK的使用授权,但是一直遭到拒绝,直到Oracle公司收购了Sun公司之后,双方关系越闹越僵,最终导致Apache愤然退出JCP(Java Community Process)组织,这是目前为止Java社区最严重的一次“分裂”。
在Sun将JDK开源形成OpenJDK之后,Apache Harmony开源的优势被极大地削弱,甚至连Harmony项目的最大参与者IBM公司也宣布辞去Harmony项目管理主席的职位,并参与OpenJDK项目的开发。虽然Harmony没有经过真正大规模的商业运用,但是它的许多代码(基本上是Java库部分的代码)被吸纳进IBM的JDK 7实现及Google Android SDK之中,尤其是对Android的发展起到了很大的推动作用。
7. Microsoft JVM及其他
在十几年的Java虚拟机发展过程中,除去上面介绍的那些被大规模商业应用过的Java虚拟机外,还有许多虚拟机是不为人知的或者曾经“绚丽”过但最终湮灭的。我们以其中微软公司的JVM为例来介绍一下。
也许Java程序员听起来可能会觉得惊讶,微软公司曾经是Java技术的铁杆支持者(也必须承认,与Sun公司争夺Java的控制权,令Java从跨平台技术变为绑定在Windows上的技术是微软公司的主要目的)。在Java语言诞生的初期(1996年~1998年,以JDK 1.2发布为分界),它的主要应用之一是在浏览器中运行Java Applets程序,微软公司为了在IE3中支持Java Applets应用而开发了自己的Java虚拟机,虽然这款虚拟机只有Windows平台的版本,却是当时Windows下性能最好的Java虚拟机,它在1997年和1998年连续两年获得了《PC Magazine》杂志的“编辑选择奖”。但好景不长,在1997年10月,Sun公司正式以侵犯商标、不正当竞争等罪名控告微软公司,在随后对微软公司的垄断调查之中,这款虚拟机也曾作为证据之一被呈送法庭。这场官司的结果是微软公司赔偿2000万美金给Sun公司(最终微软公司因垄断赔偿给Sun公司的总金额高达10亿美元),承诺终止其Java虚拟机的发展,并逐步在产品中移除Java虚拟机相关功能。具有讽刺意味的是,到最后在Windows XP SP3中Java虚拟机被完全抹去的时候,Sun公司却又到处登报希望微软公司不要这样做。Windows XP高级产品经理Jim Cullinan称:“我们花费了3年的时间和Sun打官司,当时他们试图阻止我们在Windows中支持Java,现在我们这样做了,可他们又在抱怨,这太具有讽刺意味了。”

我们试想一下,如果当年Sun公司没有起诉微软公司,微软公司继续保持着对Java技术的热情,那Java的世界会变得怎么样呢?.NET技术是否会发展起来?但历史是没有假设的。

四:展望java技术的未来

1、模块化
它将自己长期依赖JRE的结构,转变成以Module为基础的组件,这感觉就像一个壮士,需要把自己的胳膊,腿等。
2、混合语言
单一的java开发已经无法满足当前软件设计的复杂需求,而每种语言都可以针对自己擅长的方面更好的解决问题,所以多语言混合编程正在成为主流。对于运行在java虚拟机之上,java之外的语言,来自系统级别,底层的支持正在迅速增强,如InvokeDynamic指令,java.lang.invoke包,Da Vinci Machine项目等。
3、多核并行
4、进一步丰富语法
5、64位虚拟机
java虚拟机虽然很早就推出了支持64位系统的版本,但java程序运行在64位虚拟机上需要付出较大的额外代价,后续支持会进一步完善。

第二篇:Java内存区域

一:Java虚拟机运行时数据区


1、程序计数器:程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
2、Java虚拟机栈:线程私有,声明周期同线程相同。每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程,经常有人把java内存分为堆内存和栈内存,堆上存放对象栈上存放对象的引用,这个栈指的就是虚拟机栈,或者说是虚拟机栈中局部变量表部分,注意区分java虚拟机栈和操作数栈的概念:栈帧在java虚拟机栈中出入栈,操作数栈是栈帧的组成部分。
3、本地方法栈:本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
4、Java堆:Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块,被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。所以Java堆是垃圾收集器管理的主要区域,从内存回收的角度来看,Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间、old空间。
5、方法区:方法区(Method Area)与Java堆一样,是各个线程共享的内存区域它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。很多人都更愿意把方法区称为“永久代”(Permanent Generation),但是并不等价,仅仅是hotspot虚拟机设计用永久代来实现方法区,其它虚拟就如J9,JRockit并没有永久代的概念。
6、运行时常量池:运行时常量池(Runtime Constant Pool)是方法区的一部分。用于存放编译期生成的各种字面量和符号引用,class文件的类结构有中有一部分叫常量池,就是用来存放各种字面量和符号引用的,在类加载的时候这部分数据会被加载到运行时常量池中,注意区分这两个概念。
7、直接内存:直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,由于在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

二:对象的创建

java是面向对象的语言,因此对象的创建无时无刻都存在。在语言层面,使用new关键字即可创建出一个对象。但是在虚拟机中,对象创建的创建过程则是比较复杂的。

首先,虚拟机运到new指令时,会去常量池检查是否存在new指令中包含的参数,比如new People(),则虚拟机首先会去常量池中检查是否有People这个类的符号引用,并且检查这个类是否已经被加载了,如果没有则会执行类加载过程。

在类加载检查过后,接下来为对象分配内存当然是在java堆中分配,并且对象所需要分配的多大内存在类加载过程中就已经确定了。为对象分配内存的方式根据java堆是否规整分为两个方法:

a、指针碰撞(Bump the Pointer):如果java堆是规整的,即所有用过的内存放在一边,没有用过的内存放在另外一边,并且有一个指针指向分界点,在需要为新生对象分配内存的时候,只需要移动指针画出一块内存分配和新生对象即可,

b、空闲列表(Free List):当java堆不是规整的,意思就是使用的内存和空闲内存交错在一起,这时候需要一张列表来记录哪些内存可使用,在需要为新生对象分配内存的时候,在这个列表中寻找一块大小合适的内存分配给它即可。而java堆是否规整和垃圾收集器是否带有压缩整理功能有关。

具体采用哪种方式分配和java堆是否规整有关,是否规整又和采用的垃圾收集器是否带有压缩整理功能决定。

在为新生对象分配内存的时候,同时还需要考虑线程安全问题。因为在并发的情况下内存分配并不是线程安全的。有两种方案解决这个线程安全问题:

a、为分配内存空间的动作进行同步处理;

b、为每个线程预先分配一小块内存,称为本地线程分配缓存(Thread Local Allocation Buffer, TLAB),哪个线程需要分配内存,就在哪个线程的TLAB上分配。

内存分配后,虚拟机需要将每个对象分配到的内存空间初始化为0值(不包括对象头),这也就是为什么实例字段可以不用初始化,直接为0的原因。

接来下,虚拟机对对象进行必要的设置,例如这个对象属于哪个类的实例,如何找到类的元数据信息。对象的哈希吗、对象的GC年代等信息,这些信息都存放在对象头之中。

执行完上面工作之后,所有的字段都为0,接着执行指令,把对象按照程序员的指令进行初始化,这样一个对象就完整的创建出来

总结一下整体流程:
1、分配该内存空阿
2、初始化内存空间为0值
3、设置对象头信息
4、执行对象init()方法

三:对象的内存布局

对象在内存的存储布局中包括:对象头、实例数据、对齐填充
对象头(Header):包含两部分信息。1、存储对象自身的运行时数据,比如哈希码、GC分代年龄等;2、类型指针:通过这个指针确定这个对象属于哪个类。
实例数据(Instance Data):存储代码中定义的各种类型的字段内容。
对齐填充(Padding):这部分信息没有任何意义,仅仅是为了使得对象占的内存大小为8字节的整数倍

四:对象的访问定位

创建对象是为了使用对象,java程序需要通过栈上的reference数据来操作堆上的具体对象。目前主流的访问对象方式有使用句柄和直接指针两种。

1、使用句柄方式:会在java堆中创建一个句柄池,reference指向的这块句柄池,句柄池中包括两个指针,其中一个指针指向对象实例数据,另外一个指针指向对象的类型数据。

2、使用指针的方式:reference存储的直接就是对象的地址。

两种方式各有各的特点,如果使用句柄方式的话,最大的好处是reference存放的是稳定的句柄地址,在对象移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。使用指针的方式优势则是速度快,并且省去了一次指针定位的开销,Sun hotspot使用这种方式。

第三篇:垃圾收集器与内存分配策略

一:如何确定一个对象是否存活

1、引用计数法:在Java中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果没有任何与之关联的引用,则说明对象不太可能再被用到,那么这个对象就是可回收对象。这种方式即是引用计数法。但是这个方法有一个缺点就是无法解决循环引用的问题。

2、可达性分析:为了解决引用计数法的循环引用问题,Java使用了可达性分析的方法。通过一系列的“GC roots”对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。要注意的是,不可达对象并不是非死不可的,要真正宣告一个对象死亡至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。

如图: Object1-4为仍然存活对象,Object5-7为判定可回收的对象

Java中可以作为GC Roots的对象
虚拟机(栈帧中的本地变量表)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
本地方法栈中JNI(即一般说的native方法)中引用的对象

二:典型的垃圾回收算法

1、标记清除算法:最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间(该算法的缺点就是内存碎片化严重)。如图:

2、复制算法:为了解决Mark-Sweep算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉(缺点是内存利用效率不高,存活对象多的时候效率比较低),如下图,商用虚拟机采用这种算法收集新生代,新生代对象98%都是朝生夕死,所以并不需要按1:1划分内存空间,而是将内存划分为较大的一块Eden空间和两块娇小的Survivor空间,每次使用Eden空间和其中一块Survivor空间,回收时将两块空间上还存活的对象拷贝到另一个Survivor空间上去,最后清理掉这两块空间,HotSpot默认Eden和Survivor空间的比例是8:1,也就是说每次新生代可用内存为整体容量的90%。

3、标记压缩算法:结合了以上两个算法,为了避免缺陷而提出。标记阶段和Mark-Sweep算法相同,标记后不是清理对象,而是将存活对象移向内存的一端,然后清除端边界外的对象。

4、分代收集算法:分代收集法是目前大部分JVM所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将GC堆划分为老年代(Tenured/Old Generation)和新生代(Young Generation)。新生代的特点是每次垃圾回收时都有大量对象死去少量对象存活(朝生夕死),那就选用复制算法收集。老年代的特点是对象存活率高没有额外的空间对它进行分配担保,选用标记清理或者标记整理算法回收,

三:垃圾收集器

垃圾收集算法是垃圾收集器的理论基础,而垃圾收集器就是其具体实现。下面介绍HotSpot虚拟机提供的几种垃圾收集器。

Serial/Serial Old:最古老的收集器,是一个单线程收集器,用它进行垃圾回收时,必须暂停所有用户线程(Stop-The-World)。Serial是针对新生代的收集器,采用Copying算法;而Serial Old是针对老生代的收集器,采用Mark-Compact算法。优点是简单高效,缺点是需要暂停用户线程。
ParNew:Seral/Serial Old的多线程版本,使用多个线程进行垃圾收集。

Parallel Scavenge:新生代的并行收集器,回收期间不需要暂停其他线程,采用Copying算法。该收集器与前两个收集器不同,主要为了达到一个可控的吞吐量。

Parallel Old:Parallel Scavenge的老生代版本,采用Mark-Compact算法和多线程。

CMS:Current Mark Sweep收集器是一种以最小回收时间停顿为目标的并发回收器,因而采用Mark-Sweep算法。

G1:G1(Garbage First)收集器技术的前沿成果,是面向服务端的收集器,能充分利用CPU和多核环境。是一款并行与并发收集器,它能够建立可预测的停顿时间模型。

四:内存分配策略

新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。

老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

1、对象优先在Eden分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
2、大对象(需要大量连续内存空间的Java对象)直接进入老年代,比如长字符串或者数组
3、长期存活的对象将进入老年代:如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。
4、但是如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就会可以直接进入老年代。
5、空间分配担保:在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

第四篇:类文件的结构

JVM作为一个通用的、机器无关的执行平台,任何其他语言的实现者都可以将Java虚拟机作为语言的产品交付媒介。理论上任何语言编写的程序都可以运行在JVM上面,只要你代码在编译的时候生成的是符合Java虚拟机编程规范的.class文件,虚拟机并不关心Class的来源是何种语言。

一:class文件结构

Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列再class文件中,class文件格式采用一种类似C语言结构体的伪结构来存储数据,该结构只有两种数据类型:无符号数和表:
无符号数属于基本数据类型,以u1、u2、u4、u8来分别代表1个字节,2个字节,4个字节、8个字节的无符号数,无符号数可以用来描述数字,索引引用,数量值或者按照UTF-8编码构成字符串流。
表是由多个无符号数或者其它表作为数据项构成的复合数据类型,所有表都习惯以“_info”结尾。

1、每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件,很多文件存储标准中都有使用魔数进行身份识别如图片格式gif或者jpeg等文件头中都有魔数,class文件的魔数很有浪漫气息,值为0xCAFEBABE,是因为它象征着著名的咖啡品牌Peet’s Coffee中深受欢迎的Baristask咖啡。

2、紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version);

3、紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型。

常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值,从1开始。常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)
字面量:文本字符串、声明为final的常量值等。
符号引用:类和接口的全限定名(Fully Qualified Name),字段的名称和描述符(Descriptor),方法的名称和描述符。

在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

4、在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。

5、类索引、父类索引和接口索引集合都按顺序排列在访问标志之后

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。

6、字段表(field_info)用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。

7、方法表集合:方法表的结构如同字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符引(descriptor_index)、属性表集合(attributes)几项。

在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名 [2] ,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名中,因此Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。

8、属性表(attribute_info)在前面的讲解之中已经出现过数次,在Class文件、字段表、方法表都可以携带自己的属性表集合。

下图所示的是使用十六进制编辑器打开的class文件的结构


解读:
1、前4个字节16进制表示0xCAFEBABE 固定不变的魔数。
2、看到第5字节和6字节表示副版本号0x0000和主版本号0x0033也就是十进制51。查找class版本号可知这个class文件可以被JDK1.7.0,或者以上的虚拟机执行的class文件。
3、常量池计数器是从1开始计数,即上图中的偏移地址(0x00000008)即数字0x0016那,换做十进制为22,也就是常量池中有21个常量。索引值范围1~21, 这里注意:将索引值设置为0时有特殊含义,不引用任何一个常量池项目的含义。Class文件中只有常量池的容量是从1计数开始。其它一般从0开始
4、常量池:常量池第一项,(偏移地址0x0000000A)是0x07,查看表6-3的标志发现它属于CONSTANT_Class_info类型。此类型的结构如上面的常量池的6-4图,其中tag是标志位已经说过了用于区分常量类型,name_index是一个索引值,它指向常量池中一个CONSTANT_Utf8_info类型的常量,这里name_index的值(偏移地址0x0000000B)为0x0002也指向了常量池的第二项。然后依次继续查找。。。

二:字节码指令

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。下面是Test类和calc()方法的字节码指令。

public class Test {public int calc() {int a = 100;int b = 200;int c = 300;return (a + b) * c;}}

字节码指令

 0 bipush 1002 istore_13 sipush 2006 istore_27 sipush 300
10 istore_3
11 iload_1
12 iload_2
13 iadd
14 iload_3
15 imul
16 ireturn

解释一下

1、偏移地址为0的指令,bipush指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶,后跟一个参数,指明推送的常量值,这里是100。

2、偏移地址为2的指令,istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第1个局部变量Slot中。后面四条指令(3、6、7、10)都是做同样的事情,也就是在对应代码中把变量a、b、c赋值为100、200、300。后面四条指令的图就不重复画了。

3、偏移地址为11的指令,iload_1指令的作用是将局部变量第1个Slot中的整型值复制到操作数栈顶。

偏移地址12的指令,iload_2指令的执行过程与iload_1类似,把第2个Slot的整型值入栈。。

偏移地址为13的指令,iadd指令的作用是将操作数栈中前两个栈顶元素出栈,做整型加法,然后把结果重新入栈。在iadd指令执行完毕后,栈中原有的100和200出栈,它们相加后的和300重新入栈。

偏移地址为14的指令,iload_3指令把存放在第3个局部变量Slot中的300入栈到操作数栈中。这时操作数栈为两个整数300,。

偏移地址为15的指令,imul是将操作数栈中前两个栈顶元素出栈,做整型乘法,然后把结果重新入栈,这里和iadd指令执行过程完全类似,所以就不重复画图了。


偏移地址为16的指令,ireturn指令是方法返回指令之一,它将结束方法执行并将操作数栈顶的整型值返回给此方法的调用者。到此为止,该方法执行结束。

字节码按用途大致分为以下9类:
1、加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈(见第2章关于内存区域的介绍)之间来回传输,这类指令包括如下内容。
a、将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
b、将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。
c、将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
d、扩充局部变量表的访问索引的指令:wide。存储数据的操作数栈和局部变量表主要就是由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。

2、运算指令:运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。

加法指令:iadd、ladd、fadd、dadd。
减法指令:isub、lsub、fsub、dsub。
乘法指令:imul、lmul、fmul、dmul。
除法指令:idiv、ldiv、fdiv、ddiv。

求余指令:irem、lrem、frem、drem。

3、类型转换指令:类型转换指令可以将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作,如i2d: int类型转double类型,d2l:double类型转longl类型。

4、对象创建与访问指令:虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。

创建类实例的指令:new。
创建数组的指令:newarray、anewarray、multianewarray。
访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic。
把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。
将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。
取数组长度的指令:arraylength。

检查类实例类型的指令:instanceof、checkcast。

5、操作数栈管理指令:如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈的指令比如将操作数栈的栈顶一个或两个元素出栈:pop、pop2。

6、控制转移指令:控制转移指令可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,如条件分支ifeq、无条件分支goto。

7、方法调用和返回指令:比如invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派)。

8、异常处理指令:在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现,除了用throw语句显式抛出异常情况之外,Java虚拟机规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。

9、同步指令:Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的,java虚拟机的指令集中有monitorenter和monitorexit两条指令支持synchronized关键字。

三:总结

Java虚拟机规范描绘了Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集。
Class文件结构自Java虚拟机规范第1版订立以来,已经有十多年的历史。这十多年间,Java技术体系有了翻天覆地的改变,JDK的版本号已经从1.0提升到了1.7。相对于语言、API以及Java技术体系中其他方面的变化,Class文件结构一直处于比较稳定的状态,Class文件的主体结构、字节码指令的语义和数量几乎没有出现过变动 ,所有对Class文件格式的改进,都集中在向访问标志、属性表这些在设计上就可扩展的数据结构中添加内容。
Class文件格式所具备的平台中立(不依赖于特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是Java技术体系实现平台无关、语言无关两项特性的重要支柱。

第五篇:虚拟机类加载机制

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking)。

一:类加载的时机

虚拟机规范严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
5)当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

二:类加载的过程

1、加载:“加载”是“类加载”(Class Loading)过程的一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
1.1、通过一个类的全限定名来获取定义此类的二进制字节流。
1.2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
1.3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

2.验证:验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
2.1、文件格式验证
是否以魔数0xCAFEBABE开头。主、次版本号是否在当前虚拟机处理范围之内。常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
2.2、元数据验证
这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。这个类的父类是否继承了不允许被继承的类(被final修饰的类)。如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
2.3、字节码验证:第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
2.4、符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。

3.准备:准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值。假设一个类变量的定义如下:

public static int value=123;

那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。但是假如按如下方式定义:

public static final int value=123;

由于现在类的变量值变成了一个常量,那么虚拟机在准备的时候就会给这个值赋值为123。

4.解析:
4.1、类或接口的解析
4.2、字段解析
4.3、类方法解析
4.4、接口方法解析

5.初始化:类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。
5.1、<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
5.2、<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
5.3、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。

三:类加载器

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果,看下面代码的返回结果,我们就会发现如此。

/*** @author 2018年7月6日12:02:41* 测试不同的ClassLoader加载对象*/
public class ClassLoaderTest {public static void main(String[] args) throws Exception {ClassLoader myLoader = new ClassLoader(){@Overridepublic Class<?> loadClass (String name) throws ClassNotFoundException {try {String fileName = name.substring(name.lastIndexOf(".")+1)+".class";InputStream is = getClass().getResourceAsStream(fileName);if(is == null){return super.loadClass(name);}byte[] b = new byte[is.available()];is.read(b);return defineClass(name, b,0,b.length);} catch(IOException e){throw new ClassNotFoundException(name);}}};Object obj = myLoader.loadClass("com.segmentfault.springbootlesson1.pratice.ClassLoaderTest").newInstance();System.out.println(obj.getClass());System.out.println(obj instanceof com.segmentfault.springbootlesson1.pratice.ClassLoaderTest);}
}

打印的结果如下:

四:类加载器类型

从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现 [1] ,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

从Java开发人员的角度来看,类加载器还可以划分得更细致一些,绝大部分Java程序都会使用到以下3种系统提供的类加载器。

1.1、启动类加载器(Bootstrap ClassLoader):前面已经介绍过,这个类将器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可
1.2、扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

1.3、应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher $App-ClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器
我们的应用程序都是由这3中类加载器互相配合进行加载的,如有有必要还可以加入自己定义的类加载器,例如主流的Java Web服务器,如Tomcat、Jetty、WebLogic等都实现了自己定义的类加载器。

五:双亲委派模型

类加载器之间的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model),双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

双亲委派模型的好处:使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有
使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。

第六篇:虚拟机字节码执行引擎

执行引擎是Java虚拟机最核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

一:运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回地址和附加信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程,下图是栈帧的概念结构:

二:局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,在java程序编译成class文件时,就在方法的code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量(如选图)。

局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot暂用的内存空间大小,只是很有“导向性”地说明每个Slot都应该能存放一个32位以内的boolean,byte,char,short,int,float,refrence,returnAddress这8种类型的数据。

对于64位的数据类型,虚拟机会以高位在前的方式为其分配两个连续的Slot空间。Java语言中明确的(reference类型则可能是32位也可能是64位)64位的数据类型只有long和double两种。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot。对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个,Java虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常,。

在方法执行时,虚拟机是使用局部变量表完成参数到参数变零表的传递过程的,如果执行的是实例方法,那局部变量表中第0位索引的slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this来访问到这个隐含的参数,其余参数按照顺序排列占用从1开始的局部变量slot,然后再分配方法体内部定义变量的slot,讲字节码指令的时候应该有印象如下图。

为了尽可能节省栈帧空间,局部变量表中的slot是可以重用的,方法体重定义的变量其作用域并不一定会覆盖这个方法体,如果当前字节码PC计数器的值已经超出了某个变零的作用域,那这个变量对应是slot就可以交给其它变量使用。

三:操作数栈

后进先出(Last-In-First-Out),也可以称之为表达式栈(Expression Stack)。操作数栈和局部变量表在访问方式上存在着较大差异,操作数栈并非采用访问索引的方式来进行数据访问的,而是通过标准的入栈和出栈操作来完成一次数据访问。每一个操作数栈都会拥有一个明确的栈深度用于存储数值,一个32bit的数值可以用一个单位的栈深度来存储,而2个单位的栈深度则可以保存一个64bit的数值,当然操作数栈所需的容量大小在编译期就可以被完全确定下来,并保存在方法的Code属性中。

请看下面的执行加法运算的字节码指令:

public void testAddOperation();  Code:  0: bipush        15  2: istore_1  3: bipush        8  5: istore_2  6: iload_1  7: iload_2  8: iadd  9: istore_3  10: return 

在上述字节码指令示例中
1、首先会由“bipush”指令将数值15从byte类型转换为int类型后压入操作数栈的栈顶(对于byte、short和char类型的值在入栈之前,会被转换为int类型),当成功入栈之后,“istore_1”指令便会负责将栈顶元素出栈并存储在局部变量表中访问索引为1的Slot上。
2、接下来再次执行“bipush”指令将数值8压入栈顶后,通过“istore_2”指令将栈顶元素出栈并存储在局部变量表中访问索引为2的Slot上。
3、“iload_1”和“iload_2”指令会负责将局部变量表中访问索引为1和2的Slot上的数值15和8重新压入操作数栈的栈顶,紧接着“iadd”指令便会将这2个数值出栈执行加法运算后再将运算结果重新压入栈顶,“istore_3”指令会将运算结果出栈并存储在局部变量表中访问索引为3的Slot上。
4、最后“return”指令的作用就是方法执行完成之后的返回操作。

java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的栈就是操作数栈。

四:动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。我们知道Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

五:方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。

六:附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中。

七:方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,但我们知道,Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。
7.1、解析:
所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
7.2、分派:众所周知,Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征:继承、封装和多态。其中分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟机之中是如何实现的,这里的实现当然不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。
7.2.1、静态分派:

/*** @author 2018年7月6日15:53:21*/
public class StaticDispatchTest {static abstract class Human{}static class Man extends Human{}static class Woman extends Human{}public void sayHello(Human guy){System.out.println("hello,guy!");}public void sayHello(Man guy){System.out.println("hello,gentleman!");}public void sayHello(Woman guy){System.out.println("hello,lady!");}public static void main(String[]args){Human man=new Man();Human woman=new Woman();StaticDispatchTest sr=new StaticDispatchTest();sr.sayHello(man);sr.sayHello(woman);}
}

输出结果:

我们把上面代码中的“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

 public static void main(String[]args){Human man=new Man();Human woman=new Woman();StaticDispatchTest sr=new StaticDispatchTest();sr.sayHello((Man) man);sr.sayHello((Woman) woman);}

那么输出结果就是:

虚拟机(准确的说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的,而静态类型是编译期可知的,因此在编译阶段,java编译器会根据参数的静态类型决定使用哪个重载版本。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。

重装方法按照一定的优先级进行匹配,顺序按照虚拟机能够把你传进去的量进行自动类型转换的优先级,请看下面代码:

/*** @author 2018年7月6日16:25:26*/
public class OverLoadTest {public static void sayHello(Object arg){System.out.println("hello Object");}public static void sayHello(int arg){System.out.println("hello int");}public static void sayHello(long arg){System.out.println("hello long");}public static void sayHello(Character arg){System.out.println("hello Character");}public static void sayHello(char arg){System.out.println("hello char");}public static void sayHello(char... arg){System.out.println("hello char……");}public static void sayHello(Serializable arg){System.out.println("hello Serializable");}public static void main(String[]args){sayHello('a');}
}

上面代码会输出:

hello char 

如果注释掉sayHello(char arg) 方法,那输出会变为:

hello int

这时候发生了自动类型转换,‘a’除了可以代表一个字符,还可以代表数字97,我们继续注释掉sayHello(int arg)方法,那输出会变为:

hello long

这时发生了两次自动转型,a转换成97后,进一步转型为97L,匹配了参数类型为long的重载,我们继续注释掉sayHello(long arg)方法,那输出变为:

hello Character

这时发生了一次自动装箱,依次类推,这个例子演示了编译期间静态分派目标的过程,这个过程也是java语言实现方法重载的本质。

7.2.2、动态分派:了解了静态分派,我们接下来看一下动态分派的过程,它和多态性的另外一个重要体现 ——重写(Override)有着很密切的关联。

/*** 演示动态分配* @author 2018年7月6日16:33:58*/
public class DynamicDispatchTest {static abstract class Human{protected abstract void sayHello();}static class Man extends Human{@Overrideprotected void sayHello(){System.out.println("man say hello");}}static class Woman extends Human{@Overrideprotected void sayHello(){System.out.println("woman say hello");}}public static void main(String[]args){Human man=new Man();Human woman=new Woman();man.sayHello();woman.sayHello();man=new Woman();man.sayHello();}
}

输出结果:

那么虚拟机如何知道要调用哪个方法呢?

我们之前文章讲过字节码指令,我们在调用方法的时候会生成invokevirtual指令,这个指令执行步骤如下:

1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

7.2.3、单分派和多分派

/*** 测试单分派多分派* @author 2018年7月6日16:51:23*/
public class SingleOrMoreDispatchTest {static class QQ{}static class _360{}public static class Father{public void hardChoice(QQ arg){System.out.println("father choose qq");}public void hardChoice(_360 arg){System.out.println("father choose 360");}}public static class Son extends Father{@Overridepublic void hardChoice(QQ arg){System.out.println("son choose qq");}@Overridepublic void hardChoice(_360 arg){System.out.println("son choose 360");}}public static void main(String[]args){Father father=new Father();Father son=new Son();father.hardChoice(new _360());son.hardChoice(new QQ());}}

方法的接受者和方法的参数统称为方法的宗量,来看看编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型

再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(newQQ())”这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型

7.2.4、虚拟机动态分配的实现

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Vritual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Inteface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

八:基于栈的字节码解释执行引擎

许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。

Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。中间的那条分支,自然就是解释执行的过程。

九:基于栈的指令集与基于寄存器的指令集

java编译器输出的指令流基本上是一种基于栈的指令集架构。基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供 ,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。例如,现在32位80x86体系的处理器中提供了8个32位的寄存器,而ARM体系的CPU(在当前的手机、PDA中相当流行的一种处理器)则提供了16个32位的通用寄存器。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。所以主流物理机的指令集都是寄存器架构。


转载自:
对部分内容做了整理和修改

更多推荐

《深入了解java虚拟机》学习总结

本文发布于:2024-03-11 19:29:24,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/1729732.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:虚拟机   java

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!