深入理解java虚拟机


title: 深入理解java虚拟机
date: 2020-01-23 15:48:23
tags: [java虚拟机]
typora-copy-images-to: ./深入理解java虚拟机
typora-root-url: ../_posts


1.java内存

image-20200128150152686

1.1内存区域划分

java虚拟机会把内存分成几个不同的区域,都有各自的用途

img

程序计数器(program counter register)

pc寄存器用来指向接下来执行的字节码指令的地址,通过更新pc指令的值来连续执行指令.每个线程都有自己的pc寄存器

java虚拟机栈

java虚拟机栈也是线程私有的.每个方法都会形成一个栈帧.保存在虚拟机栈中.栈帧中保存局部变量表(也就是方法的形参和方法内的局部参数),操作数栈(用来执行方法内操作的操作栈),动态连接(连接到别的方法),方法出口(方法的返回路径)等.

每一个方法执行到完成的过程,就对应一个栈帧在虚拟机中入栈并出栈.

局部变量表在编译的时候就已经确定了空间.当进入一个方法是这个方法需要再栈帧中分数多大的局部变量空间是完全确定的。

本地方法栈(nativ method stack)

本地方法栈应该叫原生方法栈更合适,他是执行nativ方法的调用栈。

java堆

java堆是所有线程共享的一块内存区域,在虚拟机启动时创建,此类区域的目的就是存放对象,几乎所有的对象都在这里分配同时,内存回收也主要针对这里.

Java堆中还可以细分为新生代和老年代,同时为每个线程分配的缓冲区。
java堆由有线程共享。

方法区

方法区是被所有线程共享的区域,它用来存储被虚拟机加载的类信息及常量(final),静态变量(static)和编译后的代码(code)。

有的虚拟机也把方法区称为内存永久代。

运行时常量池

运行时常量池是方法缺的一部分。以保存编译器的各种字面量和符号引用和字符串。运行期间新的常量也可以进入运行时常量池. String字符串就在运行时常量池.

直接内存

直接内存并不是虚拟机运行时的一部分,而是额外的一部分内存,但是可以直接分配用来进行io读写。

1.2对象的创建

加载类

当虚拟机遇到new指令时,先去检查这个指令的参数是否能定位到某个类。再判断这个类是否被加载,解析和初始化过。如果没有则先会加载解析这个类。在进行对象的分配。对象所需内存的大小在类加载完成后就确定了。为对象分配空间就等于在Java堆中分配一块确定大小的内存。

分配对象内存

对象分配内存,在多线程情况下可能不安全,有两种解决办法,一是对分配内存空间的动作,进行同步处理(一次只能允许一个线程操作),另一种办法是把内存分配的动作按照线程划分在不同空间,也就是每个线程在Java堆中预先分配一小块内存称为本地线程分配缓冲(threa loacl allocation buffer,TLAB)。哪个线程分配对象就在哪个线程的TLAB中分配。当这个线程的缓存TLAB用完时,才需要同步锁定,将缓存中的内容同步到java堆中。

初始化空间为0

​ 内存分配完成后,虚拟机将分配的内存空间初始化为0。如果使用TLVB,在TLVB中也初始化为0。

分配对象头

​ 接下来对对象的对象头进行设置,对象头包括对象属于哪个类的实例,如何找到对象的元数据,对象的哈希码,gc分代信息等一些额外的参数。

对象初始化

执行对想的方法,按照程序员的意愿来初始化对象

init方法:
.Java文件在编译后会在字节码文件中生成init方法,该方法被称之为实例构造器。init方法是在对象实例化时执行的。该方法中的操作及其顺序为
1.父类变量初始化  2.父类语句块  3.父类构造函数  4.子类变量初始化 5.子类语句块  6.子类构造函数

1.3对象的内存布局

对象在内存中分成三个部分,对象头,实例数据,和对齐填充

对象头

对象头分两部分,第1部分用于存储对象自身的运行时数据,如哈希码,gc分代年龄,锁状态,线程持有的锁等,通常是32或64位。第2部分是类型指针,用来指示该对象属于哪类.如果对象是数组,还需要记录数组长度.

实例数据

实例数据是对象存储有效的信息,有从父类继承下来的属性和自己定义的属性。(代码并不在内存中对象的区域,而在上节的方法区)

对齐填充

对其填充是为了让虚拟机对内存地址的操作是8字节的整倍数,提高执行的速度。但这个并不强制。

1.4对象的访问

通过栈上的reference引用,可以指向并操作堆上的对象。这里有两种方式,。

使用句柄

reference引用指向句柄,句柄指向对象。需要在内存中划出一块作为句柄池.(句柄其实就是一个指针.)这种方式类似于间接引用,好处就是栈上的reference引用,不必直接操作内存地址。这样在堆中的对象进行内存回收而导致地址移动后,只需要更新句柄,不需要更新栈上的reference引用.

image-20200124100422538

使用指针

指针则是reference引用直接指向对象的地址。这是一种强绑定的关系.好处是节省内存.且因为是直接访问,速度快.坏处是gc后对象地址发生变化,则也需要变更账上的reference引用.

image-20200124102023409

2.垃圾收集与内存回收

image-20200128150214852

通常,垃圾收集都是针对堆上内存来说的.栈的内存会随着方法的进入退出进行分配和和释放,方法的栈帧,在类加载时就已确定大小。而在堆中。不同对象的需要内存可能不一样,需要在运行期间才能分配,因此堆上的内存分配和回收都是动态的。垃圾收集器主要关注这部分内存。

2.1判断对象死亡

引用计数器法

给对象添加一个引用计数器,每一个地方引用它时计数器就加一,当引用失效时计数器就减1,任何时刻计数器为0就表示对象没有被使用。这种方法实现简单,但是缺点却没法解决两个或多个对象互相引用,因此造成死循环的问题。主流的虚拟机都没采用这种办法。

可达性分析

选择一系列成为gc roots的对象作为起始节点,开始向下遍历。所有遍历到的对象构成引用链,引用链上的对象说明可达。不在引用链上的对象说明不可达,证明此对象不可用。

有下列对象可以作为gc roots对象。

虚拟机栈中引用的对象,方法区中类静态属性引用的对象,方法区中常量引用的对象,本地方法(native方法)中引用的对象。

引用类型

强应用就是指程序代码中普遍存在的类似A a=new A(),垃圾回收不会收集强引用的对象。
软应用会在。发生内存溢出之前,gc对这些对象进行回收,如果回收后还没有足够的内存就发生内存溢出。
弱用对象会在下一次gc发生时被回收。
虚引用对象只是用来在回收时发送一个系统通知

对象死亡过程

就像死亡要经历两次标记过程。对象在可达性分析后发现没有与GC Roots相连的应用链,就会被第1次标记,如果需要执行finanlize方法就去执行它。在finanlize方法中,如果能与gc roots 引用链进行标记,那么该对象不会被回收。如果该对象不需要执行finanlize方法就会被第2次标记,如果此时还没有与gc roots引用链进行链接。接下来的这次gc就会把它回收。

finanlize方法一定会被执行,且只会执行一次.但不保证一定能执行完,如果该方法中有耗时的操作,可能会并不执行完成。

回收方法区

方法区的回收效率通常很低,一般可以回收的是常量和无用的类.
对于常量没有任何的对象引用它时,就会被回收。比如一个字符串”abc”,当没有任何String的对象指向他时,他会在必要时被回收。
对于类,则需要该类的所有实例被回收,加载该类的class loader已经被回收。该类对应的class对象没有被引用,无法通过反射来调用该类的方法

2.2垃圾收集算法

标记-清除算法

标记清楚算法简单并且快速,标记所有需要回收的内存。标记完后统一回收被标记的对象。这种方法会产生大量不连续的内存碎片。有可能一次回收后并没有产生所需要的内存因而会再次进行垃圾收集.

image-20200124111114616

复制算法

复制算法将内存分为两半。每次只使用其中的一半内存。当这一块内存使用满了。把这块内存中所有不需要回收的对象移动到另一块。再把原来那块内存清除掉。这种算法不会产生内存碎片。代价是内存缩小为原来一半。

现在的虚拟机采用1:1:8的方式分配内存。一块大的eden空间和两个小的survivor空间.每次使用eden空间和一个survivor空间。内存满时把这两块空间中存活的对象,移动到另一个survivor空间。清空原来的agent和survivor空间.这样空间利用率就达到了1:9。survivor只能保留10%的内存。如果存活对象超过这个内存。就需要去把对象分配在老年代。这叫内存分配担保.

复制算法适合对象存活率较低的方式。而如果对象存活率较高,就需要进行内存分配担保

image-20200124111831995

标记整理算法

标记整理算法适合内存对象。存活率较高的区域。因此一般用于老年代.标记整理算法也称标记压缩。首先标记出需要存活的对象。然后把这些对象压缩到内存的一端。清理边界以外的内存。

image-20200124112345766

分代收集算法

image-20200124112448008

2.3hotSpot虚拟机实现gc

提前记录对象地址

虚拟机会在类加载完成时,知道这个类数据中哪些地方存在的对象的引用,通过一个数据结构就是提前记录对象的位置。而不需要在运行时遍历整个内存来得知对象的位置。

线程进入安全点/安全区等待gc

建立安全点和安全区,也就是在安全点和安全区中(运行中的线程进入安全点,被挂起的线程进入安全区),对象的引用数据进行更新。而在执行gc时,所有的线程也要呆在安全点和安全区中,保证此时引用,关系不发生变化。因为如果一边gc一边对象的引用发生变化,就会发生数据错乱.也就是常说的stop word,GC操作时候所有线程停止运行.停在安全点内部。

执行gc过程

Gc通常是单线程执行。在它执行时所有线程都停止,它执行完成后,其他线程再继续恢复工作。

空间分配担保

空间分配担保简单说就是新生代分配内存不足时,把对象直接分配到老年代.但是这需要老年代有足够的内存才行.不然空间分配在老年代就会失败.

2.4各种收集器简介

这里就了解下吧

image-20200125101403537
image-20200125101443975
image-20200125111459378
image-20200125111537836
image-20200125111554256
image-20200125111623164
image-20200125111632397

3.类文件结构

image-20200128150233447

.class文件是虚拟机能解释执行的输入文件. java虚拟机目前已经不是与java掌握语言进行绑定的,所有的语言,如果能够转化成class文件,都可以被Java虚拟机解释执行。

image-20200125113042760

3.1class文件结构

Class文件是一组以8位字节为基础单位的二进制流,各个数据项目按严格的顺序连续排在一起,中间没有添加任何分隔符,因此每部分数据都会指出它的大小。class文件格式是类似C语言的结构体.有两种数据类型,无符号数和表.

u1,u2,u4,u8代表1.2.4.8子节的无符号数.无符号数用来表示数字,引用,数值或字符串值.

表由多个无符号数和其他表构成

整个class文件就是一张表.如下,

image-20200125124359609

我们要知道,一个java类就构成一个class文件.内部类会存在于额外的class文件中.当描述一个数据,但数量不确定时,会使用一个前置的计数器加连续的数据项构成。例如 interfs_count和interfas. 前边先指明数据的个数.后边连续跟着interfs_count个interfaces类型的数据.因为class文件没有分隔符号,所以数据的存储顺序,大小都是严格限定的。并且class文件是大端法(big-edndian)也就是高位在前.地位在后。

magic 模数和版本

模数.4个字节,用来标识文件格式,确定这个文件是否能被虚拟机接受。java文件默认为cafebaby.其实类似于拓展名,只是拓展名可以变更模数不能变更。

minor_version 2个字节,表示次版本号. major_version主版本号.高版本可以向前兼容.虚拟机也向前兼容.

常量池 constant_pool

constant_pool_count 2字节.指明常量池个数

constant_pool 连续的常量池.

常量池中存放字面量和符号引用(符号引用就是用符号来指代各种对象和属性).其中每一项都一个表.

字面量是指由字母,数字等构成的字符串或者数值,字面量可以理解为初始值,它只能作为右值出现(等号右边)
var a = 123 //123是字面量
var b = 'test' //'test'是字面量
var arr = [4,6,78] //[4,6,78]是字面量
var obj = {a:'11',b:'222'} //{a:'11',b:'222'}是字面量
符号引用包括三方面
类的接口和全限定名 (全限定名就是 com/lang/Object/String 这种包含了类名和包名,可以定位到唯一的类)
字段的名称和描述符
方法的名称和描述符

常量池中有14中常量类型.每个类型都通过第一个u1标志位tag来标识该常量是哪个类型.然后在接该类型的数据.

所有类型汇总如下

image-20200125131740077

不同的常量类型具有不同的常量结构,把结构汇总如下。

image-20200125131800618
image-20200125131920661
一个常量池数据为开始为 0700 0201 001d   通过查表6.3.发现07是u1代表class类型常量.总共3个自己长度, 那么该常量就是 0700 02 共3个字节,后边的01 001d 是下一个常量的数据.  0002是u2表示执行权限的名常量项的索引.

访问标识 asses_flags

他们标识用于识别类或接口层次的访问信息,包括类还是接口等.采用了位模式.如下,这是用来修饰这个class文件对应的类的.

image-20200125172121338

类索引this_class . 父类索引super_class . 接口索引interface

类和父类都是u2类型.2个字节.因为只有一个.而接口索引可能是多个.因为java是单继承多实现

类索引用于确定之类的全限定名,索引用于确定这个父类的全限定名,接口索引用于确定接口的全限定名.他们都是索引,指向常量池中类型为CONSTAT_Class_info类型的常量池.(也就是说这三个索引的值都在常量池中,这里保存值在常量池中的索引.而对应的类型是常量池中CONSTAT_Class_info结构对应6-3图的类型7)。

全限定名就是包名加类名的结构.用来唯一确定该类. java/lang/object 表示object类

字段表 fields

字段表由 fields_count 表示数目,然后跟这 这个数目的fields结构组成

字段包括类变量和对象变量. 字段有如下属性,可见性(public,private,protected),类变量还是对象变量(static),是否可变(final),并发可见性(volatile),是否序列化(transient)及字段的类型(基本类型还是类类型)和字段的名称

字段表结构如下,field_info

image-20200125175441131

第一个字段访问表示acces_flags是另一个表的索引.结构如下

image-20200125175600525

这里有些访问表示可以组合,有些不可以.其实很好理解

name_index 和descriptor_index 是索引值表示字段的简单名称和字段和方法的描述符,用来唯一标识这个字段.class文件中不会列出父类的字段.父类的字段在他自己的class文件中.

public static final  int a= 10;
a就是简单名称.  int 是字段描述符(public static String Synchronize 在访问标识中已经注明了),而且字段的描述符和方法的描述符比较类似.因此是同一个结构但内容不同.

描述方法则是名称和方法描述符  
public static String   add(int a, int b) {}
add 就是方法的简单名称,  String  int int  方法的返回值,修饰符,形参构成了方法的描述符.  

因此一个字段表格描述了一个属性的全部内容.

attribute 用来描述一些额外的 属性.可有可无. 比如 fina String= 'abc' 这个abc的初始值就存在这个字段的属性中.

方法表 mothods

方法表用来标识一个方法. 通过 访问标志(access_flags),名称索引(name_index),描述符索引(descriptor_index),属性表(attributes),和字段表类似.只是具体数据有些不同

image-20200125183624212

访问表示 access_flags和字段表不太一样如下

image-20200125202042154

名称索引name_index 指向常量池中符号引用.

描述符 descriptor_index也指向常量池中. 通过名称和描述符来表示唯一的方法

描述方法则是名称和方法描述符  
public static String   add(int a, int b) {}
add 就是方法的简单名称,  String  int int  方法的返回值,修饰符,形参构成了方法的描述符. 

属性 attribute 和字段表的类似.方法中的代码.转化为字节码指令后.就保存在code属性中.

java中通过方法名,形参来确认一个方法的唯一性.class文件还可以通过返回值不同来区分方法.

属性表 attribute

因为字段表和方法表都有属性这一项目.因此把他抽象统一出来.单独做成一个表.

属性表因为用于不同的地方.因此具有很多格式.但是同一抽象为三部分,属性名称索引,属性长度.属性信息

image-20200125210920919

属性名称索引 attribute_name_index 指向常量池中的名称

属性长度 attribute_length 支出最后的属性信息info字段的长度.

info 不同的属性种类有不同的结构.但是长度已经指定了.

下边简介几个属性

code属性

code属性是字节码指令的结构

image-20200125211127346

max_stack 代表了操作数栈的最大深度(操作数栈属于线程私有的.位于内存的虚拟机栈中),操作数栈就是方法的指令码执行时需要使用的栈.

max_locals 代表局部变量所需的存储空间(局部变量也在内存的虚拟机栈中). 因此方法在执行前就已经知道局部变量的全部大小了.(局部变量包括方法的形参,方法内的局部变量.和方法内的异常参数)

code 代表字节码指令.并且已经指明了长度.

一个code属性如下
0009 0000002F 0001 0001 00000005 2AB7000AB1 
u2   u4             u2   u2   u4       5个u1
code    长度47    max_stack=1 max_locals=1    code_length =5. 2AB7000AB1是字节码指令.

这里注意,对于实力方法.就算方法是无参数的.如 void int() 也还是max_locals=1,因为有个隐含的this参数.指向这个方法所属的对象.这是编译器为我们自动加上的.编译器把对this关键字的访问转换为一个隐含的this参数传入方法.

exception_table是异常表.指示该方法内的异常处理,可有可无,结构如下.意思是.从方法的字节码指令的start_pc行到end_pc出如果出现了catch_type类的异常,跳到handle_pc行进行处理.

image-20200125213752036
异常表如下
from        to      target      table
0               5       10              java/lang/Exception     /0到5行.发生Exception异常,跳到 10行处理
0               5           21              any                                     /0到5行.发生任何异常,跳到 21行处理 也就是Exception以外的其他异常
any 通常是finally块的处理逻辑

注意,这个是方法内处理的try/catch异常.方法抛出的异常则是另一个属性表结构.

innerClasses属性

记录内部类与外部类的关联关系的表.因为内部类是独立的class文件.需要与外部类记录关系

image-20200125214840330

内部类的访问标志就是指明内部类是public|private|protected 和是否有 final static abctract等修饰符,和内部类是接口还是注解还是枚举.

其实到这里我们就发现了.所有后是inde的都是表示对常量池中的引用.

最后总结,我们的java类中,所有的结构有 .类名.父类.接口.内部类.静态函数.静态块,对象函数.对象属性等.class结构就是把这些数据一一的抽象出来形成一个一个的结构体.其中所有的字面量和符号都在常量池中.其他的部分都有指向常量池的引用.然后每个结构不定长的数据都会提前指示出数据的个数.由此形成一个完整的结构体.

一些额外的信息

对于静态变量.final修饰.如果有初始值,会有constansValue属性存储初始值.static修饰如果有初始值,会在类初始化方法方法进行初始化,对象的变量在对象的初始化方法中初始化

init方法:

.Java文件在编译后会在字节码文件中生成init方法,该方法被称之为实例构造器。init方法是在对象实例化时执行的。该方法中的操作及其顺序为

1.父类变量初始化  2.父类语句块  3.父类构造函数  4.子类变量初始化 5.子类语句块  6.子类构造函数

clinit方法:

.java文件在编译后会在字节码文件中生成clinit方法,该方法被称之为类构造器。该方法中的操作及其顺序为:

1.父类静态变量初始化 2.父类静态语句块 3.子类静态变量初始化 4.子类静态语句块   (若父类为接口,则不会调用父类的clinit方法,一个类可以没有clinit方法)

clinit一定比init先执行,因为clinit是在类加载过程中执行的,而init是在对象实例化时执行的。整个执行顺序为:

1.父类静态变量初始化  2.父类静态语句块  3.子类静态变量初始化  4.子类静态语句块   5.父类变量初始化  6.父类语句块  7.父类构造函数  8.子类变量初始化 9.子类语句块  10.子类构造函数

3.2字节码指令简介

字节码指令的特点就是针对不同的数据类型有对应的指令.比如对应int数据.指令通常有个i.对于double指令通常有个d.因此把指令归类后.可以判断出数据类型.且并不是每个指令都对应全部的数据类型.

java虚拟机采用栈的方式来执行指令.因此都是把两个操作数读入操作数栈.在执行额外的操作指令.(操作数栈在虚拟机栈中的每个栈帧上.)

加载和存储指令

用于把数据在栈帧的局部变量表和操作数栈上来回传输. 要记住.所有指令的操作都有在操作数栈上执行.因此需要把数据先读到操作数栈上.执行完在返回局部变量表上.

load 把一个局部变量加载到操作数栈上,如 iload(操作int数据), fload(操作float数据)

store 把一个数值从操作数栈上保存会局部变量表中, istore(操作int数据), fstore(操作float数据)

push 把一个常量加载到操作数栈上 ,也有 i.f.d等对应的指令.以后省略不写了

运算指令

对操作数上两个值进行某种特定运算.并把结果重新保存在操作数栈顶.

image-20200125224035849
image-20200125224046051

类型转换指令

把数据在不同的格式中转换.通常是小内存转为大内存如int转为float .反向转换可能导致数据精度丢失或结果出问题.

i2b int to byte. i2l int to long . i2d int to double

对象创建与访问

new 创建对象 newarray 创建数组

getfield putfield getstatic putstatic 访问类和对象的字段

aload 把一个数组元素加载到操作数栈

astore 把一个元素从操作数栈保存回数组

instanceof 检查对象类型

操作数栈管理

pop 弹出栈顶元素 pop2弹出栈顶2个元素

dup 复制栈顶元素,重新压入栈 dup2 复制栈顶2个元素,重新压入栈

swap 栈顶两个元素互换

控制转移指令

ifeq iflt ifne 条件分支,如果满足条件就跳转,if是跳转分支. eq .lt ne 是满足的条件.很简单.是英文缩写 eq=equal lt=letter than ne=not equal

tableswitch 复合条件分支. 就是switch语句. 后跟几个条件

goto jsr 无条件分支,执行到这里就肯定会跳转.

控制转移指令通常前边跟一个判断指令.判断指令会把结果压入栈顶.条件指令就是用这个结果来做条件判断跳转

方法调用和返回指令

image-20200125225323566

invokedynamic 指令比较特殊.他执行的方法是在运行时动态解析出来了.因为java有重写和重载的特点.有些方法在运势时才知道具体的对象是哪个.要执行的是父类的方法还是子类重写的方法还是重载的方法.因此通过这个指令实现动态执行.

同步指令

同步指令用于多线程.可以保证一段指令只能同时有由一个线程来执行.对应的就是synchronize修饰的方法和代码块. 指令中有 monitorenter 和monitorexit. 在进入和离开时进行同步保证唯一线程执行.

总结下.这里的指令和计算机的汇编指令其实比较类似.只不过这里是针对栈的.因此需要有很多出入栈的指令.同时执行方法也有了额外的指令.其实相对来说更加简单 了.

4.类加载机制

image-20200128150247956

java语言里.类的加载.连接和初始化都是在程序运行期间完成.这提供了动态扩展的灵活性.也就是在运行时可以动态选择执行对象,动态选择要执行的方法.

4.1类加载机制概括

image-20200126121651770

加载.验证.准备.初始化这几个阶段的顺序是确定的.

每使用一个对象或者类的静态属性或方法.都需要先加载类.以下五种情况必须先对类进行初始化.而初始化之前肯定已完成类的加载.

1.new.getstatic.putstatic.invokstatic的字节码指令时.也就是访问类的静态变量和方法,创建对象时.

2.使用reflect 反射调用类时.

3.初始化类时,他的父类需要初始化

4.虚拟机启动时.需要加载带有main方法的主类,此类要初始化

5.jdk1.7后动态语言,使用MethodHandle时.要初始类

反射获取的信息比MethodHandle要多。
反射是模拟java代码层面的调用,MethodHandle是模拟字节码层面的调用

接口的初始化和类差不多.不过没有第三点,接口的初始化只有在父接口被用到的时候才初始化父接口

4.2类加载过程

加载阶段

  1. 通过类的全限定名,找到这个类的二进制字节流(字节流可以来自文件.网络,数据库或运行时生成)
  2. 把这个字节流代表的静态结构转化为方法区的运行时数据结构 (数据结构保存在方法区)
  3. 在内存中生成代表这个类的class对象,作为方法区这个数据结构的访问入口.(这个class对象可在堆上可在方法区,看虚拟机实现.).
  4. 类的加载需要使用类加载器来完成.类加载器和类共同确定一个唯一的类.(同一个类由不同的加载器加载,也是两个不同的类)

加载阶段与连接阶段是部分交叉进行的.加载时也会执行一些连接的任务

验证阶段

验证阶段是保证类的二进制字节流是正常的,防止破坏虚拟机自身.主要分为文件格式验证,元数据验证.字节码验证.符号引用验证

文件格式验证.

用来验证字节流是否符号class文件格式规范,主要是对格式的校验,保证输入的字节流能正确解析

模数是否正确,

主次版本号是否正确.

常量池中格式是否都支持.

指向常量的索引能否找到对应常量.

元数据验证

验证字节流的描述信息是否符号java语言规范

类是否有父类(object意外都有父类)

类的继承关系是否正确(不能集成被final修饰的类)

是否实现了父类和接口必须实现的方法

类的字段,方法是否和父类的产生冲突(final修饰.或者不正确的重载)

字节码验证

通过数据流和控制流分析,保证程序语义是合法的. 这阶段分析方法体.保证方法执行正常.(这个应该是编译原理相关的阶段.不是很了解这方面)

符号引用验证

验证常量池中的类全限定名是否能找到对应的类

在指定类中通过字段表能否找到对应的属性,通过方法表能否找到对应的方法.

符号引用中的类,字段,方法的访问性是否合理

简单说就是看符号引用和真正的类,方法,字段能否对应起来.

准备阶段

这里是为类变量(static修饰)分配内存.在方法区中分配内存.并设置初始值(都初始化为0,或null,也就是把内存初始化干净).

如 public static int a =123; 此时会把a初始化为0 ,123的值要等类的执行时复制

而 public static final int a =123 ,则会初始化为123.因为final修饰后,123会在类的字节码文件中字段表中的constantValue 属性上出现.因此是在类文件中的.会直接初始化

那么 public final int a =123呢? 这是对象的变量.会在对象初始化的时候进行初始化.

解析阶段

解析阶段就是将常量池内的符号引用替换为直接引用的过程. 解析的过程可能会重复执行,但是如果之前解析成功.那么以后重复解析也会成功(invokedynamic指令是动态的,他解析可能失败)

符号引用就是常量池内的字符串的各种组合.可以定位到某个目标,此时这个目标不一定加载到内存中.

直接引用.此时目标已经加载到内存中了.直接引用可以是指针,句柄和相对偏移量.总之是可以定位到内存中该目标.

可以理解为.class以文件形式存在时.只是通过字符可以保存目标.当等他完全加载到内存时,就需要在内存中有个直接引用.通过这个引用可以指向目标的内存地址,从而使用目标.这里的目标就是方法.属性.类,接口和对象.

类或接口解析

假设当前代码在D类中,要加载C类.C类的符号引用是N,解析类有三部

1.如果C不是数组类,那么虚拟机把符号引用N传递给类D的类加载器.去加载类C,然后递归的加载C中依赖的其他类

2.如果C是数组类型,并且数组内容是对象,那么按照第一部加载C数组中的类.然后虚拟机生成这个数组

3.如果前两部没出异常,那么内存中就有C类的结构了.这里再验证D类是否具有C类的访问权限

字段解析

解析字段需要先解析这个字段所属的类或接口,也就是字段表中class_index 中索引的类的符号引用.转为类的直接引用.假设这个类是C,要解析的字段是D

1.如果类C中有简单名称和字段描述符都匹配的字段.就返回这个字段

2.如果类C中找不到.就去他实现的接口中从下往上递归寻找字段.找到匹配的就返回

3.如果实现的接口也找不到.就在类C的继承的父类中找匹配的字段.返回

4.没找到.匹配失败,返回 java.lang.nosuchFieldError异常

返回这个字段后还会验证这个字段的访问权限.不匹配抛出 java.lang.ILLegalAccessError异常

类方法解析

类方法也需要先解析出该方法对应的类直接饮用.假设这个类是C,要解析的方法是D

1.类方法和接口方法的符号引用是分开的.如果发现不匹配.就抛出异常

2.在类C中查找名称和描述符都匹配D的目标,如果找到.就返回这个目标

3.在类C所继承的父类及以上递归查找名称和描述符都匹配D的目标,如果找到.就返回这个目标

4.在类C所实现的接口及以上递归中查找名称和描述符都匹配D的目标,如果找到.就返回这个目标

5.查找失败,返回NoSuchMothodError

同样,如果找到后还需要进行权限验证

接口方法解析

接口方法和类方法类似.只是接口方法直接只查找自己和他的父接口就可以了.

初始化

到这里.类和接口和方法以及都加载到内存中(常量池中).并且有了直接应用指向他们.在准备阶段时,以及把类的这片内存都初始化为0或空值了.初始化阶段就是执行方法的阶段.会把 static int a =123 ; 在代码中指定的初始赋值给字段.

再次重申下 ,一个是默认的对象的初始化,一个是默认的类的初始化

init方法:
.Java文件在编译后会在字节码文件中生成init方法,该方法被称之为实例构造器。init方法是在对象实例化时执行的。该方法中的操作及其顺序为
1.父类变量初始化  2.父类语句块  3.父类构造函数  4.子类变量初始化 5.子类语句块  6.子类构造函数

clinit方法:
.java文件在编译后会在字节码文件中生成clinit方法,该方法被称之为类构造器。该方法中的操作及其顺序为:
1.父类静态变量初始化 2.父类静态语句块 3.子类静态变量初始化 4.子类静态语句块   (若父类为接口,则不会调用父类的clinit方法,一个类可以没有clinit方法)

clinit一定比init先执行,因为clinit是在类加载过程中执行的,而init是在对象实例化时执行的。整个执行顺序为:
1.父类静态变量初始化  2.父类静态语句块  3.子类静态变量初始化  4.子类静态语句块   5.父类变量初始化  6.父类语句块  7.父类构造函数  8.子类变量初始化 9.子类语句块  10.子类构造函数

虚拟机保证 在多线程环境中自动被加锁.同步,也就是同一时刻只有一个线程去初始化一个类.执行这个类的class方法.(因此我们可以利用这点做单例模式)

4.3类加载器

类和类加载器唯一确定一个类的唯一性.每个类有独立的类命名空间,不同加载器加载同一个类,得到的就是两个不同的class对象.

4.4双亲委派模型

从java虚拟机角度看.只有两种类加载器.一种是启动类加载器,由c++语言实现,是虚拟机自身一部分,剩余类加载器归为一类.由java实现,独立于虚拟机外部.

启动类加载器:加载java_home\lib路径下类.用于加载系统类

扩展类加载器:加载java_home\lib\ext路径下类,开发者可以使用这个类加载器

应用程序类加载器:一般称为系统类加载器,负责加载用户路径上的类库,这个是程序中默认的加载器.

image-20200127164445972

双亲委派模型,简单说就是有加载类的请求后.会递归要求父类加载器来加载.如果父类加载器不加载,在让子类来加载,这就保证系统库里重要的类库都是由父类加载的,因而保证系统类在各种加载器环境中都是统一的.(因为都是由共同的父类来加载.)

image-20200127164839718

5.虚拟机执行字节码

image-20200128150259762

5.1运行时栈帧结构

每个方法在运行时都会有一个栈帧结构,保存这个方法运行期间的数据.内容有局部变量表(保存局部变量和形参).操作数栈(指令执行操作使用的栈),动态连接和方法返回地址.每个栈帧分配的内存,在方法执行前就确定了.不会受到运气期间数据的影响.每个线程有自己的栈.每个方法有唯一的栈帧.

image-20200127172319653

局部变量表

局部变量用于存储方法参数和方法内定义的局部变量.局部变量以变量槽(variable slot)为最小单位.每个槽可以放一个基础数据或对象引用或返回地址. 对象引用reference 可以定位到对中的这个对象和方法区中这个对象所属的类.

同时.虚拟机默认会生成一个_this的局部变量给对象的方法,作为第一个形参,因此就额可以在方法中使用this指向所属的类.

局部变量使用前需要复制.因为虚拟机没有给局部变量进行默认赋值.

操作数栈

java虚拟机是基于栈存储执行指令的.通常他把两个数据入栈,然后执行操作时弹出这两个数据.执行完计算后在把数据保存回栈.

这个举例一看就懂了

begin  
iload_0    // push the int in local variable 0 onto the stack  局部变量表中0号元素入栈
iload_1    // push the int in local variable 1 onto the stack  局部变量表中1号元素入栈
iadd       // pop two ints, add them, push result   弹出站内两个元素,相加,结果写回栈
istore_2   // pop int, store into local variable 2  弹出栈中结果.保存在局部变量表中2号
end  

局部变量表和栈结构变化如下

image-20200127174833771

动态连接

每个栈帧都包含 一个指向运行时常量池中该栈帧所属方法 引用.通过这个引用.可以在改方法中动态调用其他的方法,因为常量池中都是符号引用,可以在运行期间,由符号引用转为对象或方法的直接引用(这种方式和静态引用类似,只是是在运行时发生,称为动态引用.)

方法返回地址

方法执行完成后,决定要跳到哪里.正常返回时可能会带值给上层调用方法者,称为正常完成出口.方法如果出错了.且异常没有包住.就异常退出了.称为异常完成出口,不会有返回值.

5.2方法调用

方法调用不是方法执行.方法调用是为了确定具体要执行那个方法.因为java中有重写和重载.因此要有一个选择的过程.class文件的编译过程不包含链接过程.一切方法调用在class里都是存储的符号引用.在运行是则要动态转为内存中方法的具体地址.

解析

类在由class文件加载到内存中时,也有一个符号引用转为直接引用的过程.那里是静态转换.转换的方法是在运行前就能确定方法唯一性的.而对于java的动态特性,有些符号引用转为直接引用则需要在运行期间完成.

静态方法(static) 和私有方法(private) 和final方法是可以静态解析的.在类加载阶段解析.虚拟机的指令中

invokestatic (调用静态方法)和invokespecial(调用实例构造器方法,私有方法和类方法)所执行的方法可以在静态解析期间得到

invokevirtual(调用虚方法),invokeinterface(调用接口方法)和invokedynamic(调用动态方法.) 都需要在运行时动态解析出符合引用对应的直接引用.

静态分派

分派分为静态分派和动态分派.就是针对java语言的多态性.在运行期间如何找到正确的方法.

方法重载使用的是静态分派.发生在编译阶段.重载通过参数确定来选择哪个类型.这个看代码比较容易理解

public class fenpai {
    static  abstract class A {}
    static class B extends A {}
    static class C extends A {}   
//这三个方法参数不同.因此是重载的
    public static void sayHi(A a) {
        System.out.println("hi ,father");
    }
    public void sayHi(B b) {
        System.out.println("hi ,sister");
    }
    public void sayHi(C c) {
        System.out.println("hi ,brother");
    }

    public static void main(String[] args) {
        fenpai f = new  fenpai();
        A a1 = new B();
        A a2 = new C();
        f.sayHi(a1);
        f.sayHi(a2);
    }
}
----结果----
hi ,father
hi ,father

可以看到.对于 A a1 = new B(); A称为变量的静态类型. B则是变量的实际类型,那么当作为形参传递给方法时.方法使用的是 变量的静态类型.也就是认为传进来的是A类的对象.因此静态类型是编译器可以知道的.通过参数的类型A,解析到了sayHi(A a) 这个方法.

简单说就是重写通过形参来确定方法,而形参取决于传入参数的静态类型.

动态分派

动态分派体现在重写上(也就是子类重写父类的方法),


public class fenpai {
    static  abstract class A {
        public void sayHi() {
            System.out.println("hi ,father");
        }
    }
    两个子类分别重写了父类的方法.
    static class B extends A {
        @Override
        public void sayHi() {
            System.out.println("hi ,brother");
        }
    }
    static class C extends A {
        @Override
        public void sayHi() {
            System.out.println("hi ,sister");
        }
    }   


   

    public static void main(String[] args) {
        fenpai f = new  fenpai();
        A a1 = new B();
        A a2 = new C();
        a1.sayHi();
        a2.sayHi();
    }
}
-----结果---
hi ,brother
hi ,sister

此时的静态类型都是A,而实际类型则是B和C,那么如何执行动态分派呢?

在执行sayHi()时,先找到这个方法的对象的实际类型(注意这里是实际类型).也就是B,

如果在B中找到名称和描述符和sayHi()一样的方法.就返回这个方法的引用

否则,依照集成关系,从子类一次向父类遍历,找到匹配,就返回这个方法的引用

否则,就抛出异常

简单说就是重载由对象的实际类型决定,从实际类型依次向父类遍历寻找方法.

其实.动态分派和静态分派解决的就是在运行期间,调用哪个对象,调用该对象的哪个方法.是为了解决java语言的重写和重载问题.

5.3字节码执行引擎

编译和解释的前边有一部分是一样的.如下

image-20200127220210107

基于栈的指令集合基于寄存器的指令集的区别.

image-20200127220304098
image-20200127220314240

基于栈指令的优点是可移植,忽略的硬件的具体实现,因为不同架构CPU的寄存器数目不一定一样.采用栈这种抽象结构,不管底层如何实现,指令都是一样的.缺点就是有大量的出栈入栈操作,频繁的内存方法..指令的数量多,会慢一些.

下边是一个栈指令集的实例讲解.很清楚了.直接抄过来.

image-20200127220854361
image-20200127220911497
image-20200127220921968
image-20200127220929632

6.编译优化

早期优化

编译过程大致分为.解析与填充符号表.注解处理器的处理注解.分析与字节码生成

image-20200127225319990

编译过程的主体方法如下

image-20200127225339922

解析与填充符号表

先j进行词法分析,语法分析,构成抽象语法书, 词法分析简单说就是把 int a = 2+6 转为标记(token),形成 int, a,=,2,+,6 六个标记的过程.语法分析构成抽象语法树.如下

image-20200127225953369

填充符号表,符号表是符号地址和符号信息的k-v的映射关系.

注解处理器处理注解

注解是一租编译插件,可以任意处理生成的抽象语法树,进行增删改查其中的元素.如果注解处理器修改了语法书.则编译器将重新进行解析和符号填充的过程.这里循环反复直到所有的注解都处理完成.

语义分析与字节码生成.

语义分析会处理代码的逻辑结构,看代码的逻辑是否有问题,查看代码的控制流程是否正确.同时去掉代码中的语法糖.(泛型就是语法糖,他在源码中存在,编译后的字节码就已经转成原生类型了.自动拆箱装箱,foreach循环也是)

晚期优化

编译器和解释器

解释器比较节省内存, 如果需要程序的快速启动.解释器可以省去编译时间.提高速度.而随着代码的主机运行,编译器则发挥作用,注解把字节码转换成本地机器码.提高执行效率.通常编译器和解释器都是同时存在的.

image-20200127230923840

对应代码中. 被多次调用的方法. 循环体重多次执行的代码.都会转换成本地机器代码. 这叫jit即时编译.

这里通过一个计时器来保存确定被多次调用的方法

image-20200127231111318
image-20200127231145248

编译的技术我不太了解.这里就不做笔记了.以后再研究了编译在做吧.

你可能感兴趣的:(深入理解java虚拟机)