JVM相关及类加载过程

JVM内存区划分(运行时数据区)

 

线程共享:方法区,堆

非线程共享:程序计数器,虚拟机栈( Java 栈), 本地方法栈

 

程序计数器(Program Counter Register)

也有称作为PC寄存器。想必学过汇编语言的朋友对程序计数器这个概念并不陌生,在汇编语言中,程序计数器是指CPU中的寄存器,它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址),当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。

  程序计数器是不会发生内存溢出现象(OutOfMemory)的。

Java栈也称作虚拟机栈(Java Vitual Machine Stack)

  也就是我们常常所说的栈,跟C语言的数据段中的栈类似。事实上,Java栈是Java方法执行的内存模型。Java栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。讲到这里,大家就应该会明白为什么在使用递归方法的时候容易导致栈内存溢出的现象了以及为什么栈区的空间不用程序员去管理了。下图表示了一个Java栈的模型:

  局部变量表:存储方法中的局部变量(包括在方法中声明的非静态变量以及函数形参)。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。

  操作数栈,想必学过数据结构中的栈的朋友想必对表达式求值问题不会陌生,栈最典型的一个应用就是用来对表达式求值。想想一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。

  指向运行时常量池的引用,因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。

  方法返回地址,当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。

  每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,互不干扰。

本地方法栈

  与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方发展的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。

  在C语言中,堆这部分空间是唯一一个程序员可以管理的内存区域。程序员可以通过malloc函数和free函数在堆上申请和释放空间。那么在Java中是怎么样的呢?

  Java中的堆是用来存储对象本身的以及数组(当然,数组引用是存放在Java栈中的)。只不过和C语言中的不同,在Java中,程序员基本不用去关心空间释放的问题,Java的垃圾回收机制会自动进行处理。因此这部分空间也是Java垃圾收集器管理的主要区域。另外,堆是被所有线程共享的,在JVM中只有一个堆

方法区

  方法区在JVM中也是一个非常重要的区域,它与堆一样,是被线程共享的区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。

  在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。

  在方法区中有一个非常重要的部分就是运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。

  在JVM规范中,没有强制要求方法区必须实现垃圾回收。很多人习惯将方法区称为“永久代”,是因为HotSpot虚拟机以永久代来实现方法区,从而JVM的垃圾收集器可以像管理堆区一样管理这部分区域,从而不需要专门为这部分设计垃圾回收机制。不过自从JDK7之后,Hotspot虚拟机便将运行时常量池从永久代移除了。

详情参考:JVM的内存区域划分 - Matrix海子 - 博客园 (cnblogs.com)


Java类加载过程

 

 

 

 

当我们调用 Java 命令运行某个 Java 程序时,该命令将会启动一条 Java 虚拟机进程,不管该 Java 程序有多么复杂,该程序启动了多少个线程,它们都处于该 Java 虚拟机进程里。同一个 JVM 的所有线程、所有变量都处于同一个进程里,它们都使用该 JVM 进程的内存区。

类加载器就是寻找类或接口字节码文件(.Class文件)进行解析并构造JVM内部对象表示的组件。

类装载器把一个类装入JVM,经过以下步骤:

  1、加载:查找和导入Class文件

                通常是创建一个字节数组读入.class文件,然后产生与所加载类对应的Class对象。加载完成后,Class对象还不完整,类还不可用。

  2、链接:其中解析步骤是可以选择的 (a)验证:检查载入的class文件数据的正确性 (b)准备:给类的静态变量分配存储空间 (c)解析:将符号引用转成直接引用

  3、初始化:对静态变量,静态代码块执行初始化工作

                           1)如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;

                           2)如果类中存在初始化语句,就依次执行这些初始化语句。

加载、 验证、准备和初始化这四个阶段的顺序是确定的,类的加载过程必须按照这种顺序接部就班地开始,而解析则不一定: 它在某些情况下可以在初始化阶段之后再开始, 这是为了支持 Java语言的运行时绑定 (也称为动态绑定或晩期绑定)。

 

加载阶段详解

在加载阶段, 虚拟机需要完成以下三件事情:

  1)通过一个类的全限定名来获取定义此类的二进制字节流。

  2 )将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

  3 ) 将类的class文件读入内存,并为之创建一个java.lang.Class对象,也就是说当程序中使用任何类时,系统都会为之建立一个java.lang.Class对象, 作为方法区这个类的各种数据的访问入  口。

通过使用不同的类加载器,可以从不同来源加载类的二进制数据,通常有如下几种来源:

  ①从本地文件系统加载class文件;
  ②从一个ZIP、 JAR、 CAB或者其他某种归档文件中提取Java class文件,JDBC编程时使用到的数据库驱动就是放在JAR文件中,JVM可以直接从JAR包中加载class文件;
  ③通过网络加载class文件,这种场景最典型的应用就是 Applet;
  ④把一个java源文件动态编译、并执行加载
  ⑤重点:运行时计算生成, 这种场景使用得最多的就是动态代理接术, 在 java.lang.reflect.Proxy中 , 就是用了 ProxyGenerator.generateProxyClass来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。

连接阶段详解

当类被加载后,系统为之生成一个对应的Class对象,接着会进入连接阶段,连接阶段将会负责把类的二进制文件合并到JRE中。


验证
验证是连接阶段的第一步, 这一阶段的目的是为了确保 Class文件的字节流中包含的信息符合当前虚拟机的要求, 井且不会危害虚拟机自身的安全。

Java语言本身是相对安全的语言,但前面已经说过, Class文件并不一定要求用 Java源码编译而来, 可以使用任何途径, 包括用十六进制编译器直接编写来产生 Class 文件。在字节码的语言层面上, 上述 Java代码无法做到的事情都是可以实现的, 至少语义上是可以表达出来的。虚拟机如果不检査输入的字节流,对其完全信任的话, 很可能会因为载入了有害的字节流而导致系统崩溃 , 所以验证是虚拟机对自身保护的一项重要工作。从整体上看,验证阶段会完成下面四个阶段的检验过程: 文件格式验证、 元数据验证、 字节码验证、符号引用验证

对于虚拟机的装加载机制来说 ,验证阶段是一个非常重要的、 但不一定是必要的阶段(因为对程序没有影响)。如果所运行的全部代码(包括自己编写的以及第三方包中的代码)都已经被反复使用和验证过 , 那么在实施阶段就可以考虑使用一Xverify;none 参数来关闭大部分的验证措施, 以缩短虚拟机类加载的时间。

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

解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程, 解新动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_IntrfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info7种常量类型


初始化阶段详解
初始化阶段是类加载过程的最后一步 , 前面的几个阶段, 除了在加载阶段用户应用程序可以通过自定义类加载器參与之外, 其余动作完全由虚拟机主导和控制。到了初始化阶段, 才真正开始执行类中定义的 Java程序代码。从代码角度,初始化阶段是执行类构造器<clinit>()方法的过程。

类的初始化阶段主要是对类变量进行初始化,在Java类中对类变量指定初始值有两种方式:

1、声明类变量时指定初始值
2、使用静态初始化块为类变量指定初始值


JVM初始化一个类一般包括如下几个步骤:

1、假如这个类还没有被加载和连接,程序先加载并连接该类;
2、假如该类的直接父类还没有被初始化,则先初始化其直接父类;
3、假如类中有初始化语句,则系统依次执行这些初始化语句
当执行第二步时,系统对直接父类的初始化也遵循此1、2、3步骤,如果该直接父类又有直接父类,系统再次重复这三步,所以JVM最先初始化的总是java.lang.Object类。


 

Java符号引用和直接引用

在类的加载过程中的解析阶段,Java虚拟机会把类的二进制数据中的符号引用替换为直接引用,如Worker类中一个方法:

public void gotoWork(){
     car.run(); //这段代码在Worker类中的二进制表示为符号引用        
}

在Worker类的二进制数据中,包含了一个对Car类的run()方法的符号引用,它由run()方法的全名 和 相关描述符组成。在解析阶段,Java虚拟机会把这个符号引用替换为一个指针,该指针指向Car类的run()方法在方法区的内存位置,这个指针就是直接引用。


 

类加载器双亲委派机制

 

 

 工作原理

(1)如果一个类加载器收到了类加载请求,它并不会自己先加载,而是把这个请求委托给父类的加载器去执行;

(2)如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的引导类加载器;

(3)如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成加载任务,子加载器才会尝试自己去加载,这就是双亲委派机制;

(4)父类加载器一层一层往下分配任务,如果子类加载器能加载,则加载此类,如果将加载任务分配至自定义加载器也无法加载此类,则抛出异常。

双亲委派机制优势

  • 防止加载同一个.class。通过委托去询问上级是否已经加载过该.class,如果加载过了,则不需要重新加载。保证了数据安全。

  • 保证核心.class不被篡改。通过委托的方式,保证核心.class不被篡改,即使被篡改也不会被加载,即使被加载也不会是同一个class对象,因为不同的加载器加载同一个.class也不是同一个Class对象。这样则保证了Class的执行安全。


 

posted @ 2021-09-27 11:09  忙碌了一整天的L师傅  阅读(76)  评论(0编辑  收藏  举报