jvm学习

前言

教程来源于B站尚硅谷官方发布的《尚硅谷2020最新版宋红康JVM教程持续更新中(java虚拟机详解,jvm从入门到精通)》,主讲是宋红康老师。这篇博客是我看视频教程做的笔记。JVM理论居多,所以说是笔记,多是老师ppt里的内容;因为除了把ppt里的内容抄写一遍,方便以后查阅巩固,我也没有更好的学习的方法了,但是在老师讲解的过程中,会提供一些生动的实例,如果是JVM小白,推荐大家去原视频教程吧。

JVM用处大吗?学了几天,我的感觉是,对于开发而言,用处也不大。因为目的是做调试、性能优化,你得先有应用;而且如果是你在开发应用的过程中,积累了一些常见的bug的解决方法,对于独立开发而言也是够用了。如果目的是破解与反编译,这又算是入门教程。

概述

JVM是什么

JVM即Java Virtual Machine,即java虚拟机的缩写。java或其他编程语言,由编译器编译成字节码,最后是在JVM平台上运行的。

JVM架构

有基于栈式架构和基于寄存器架构模式两种,由于跨平台的特性,不同cpu架构不同,所以java的指令都是根据栈来设计的。

内存结构

 

 

 

 类加载器加载过程

 

 

图1

 

 

 

图2

 

 

 加载

1.通过一个类的全限定名获取定义此类的二进制字节流。(获取.class文件的二进制流)

2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。(将类信息、静态变量、字节码、常量这些.class文件中的内容放入方法区中)

3.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。(一般这个Class是在堆里的,不过HotSpot虚拟机比较特殊,这个Class对象是放在方法区中的)

这里可以暂时理解为:将java类的二进制形式加载到内存中,并且将它缓存在内存中。

加载分为预加载和运行时加载。预加载即虚拟机启动时加载,加载的是JAVA_HOME/lib/下的rt.jar下的.class文件,这个jar包里面的内容是程序运行时非常常常用到的,像java.lang.*、java.util.*、java.io.*等等,因此随着虚拟机一起加载。运行时加载。虚拟机在用到一个.class文件的时候,会先去内存中查看一下这个.class文件有没有被加载,如果没有就会按照类的全限定名来加载这个类。

链接

验证(Verify)

·目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身的安全。
·主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证。

准备(Prepare)

·为类变量分配内存并且设置该类变量的默认初始值,即零值。分配的仅为类中static修饰的变量
·这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化。对于非final修饰的变量,设置为0值;final修饰的类变量,赋值成真实的值
·这里不会在实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到java堆中。

解析(Resolve)

·将常量池内符号引用转换为直接引用的过程。
·事实上,解析操作往往会伴随JVM在执行完初始化之后再执行。
·符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针,相对偏移量或一个间接定位到目标的句柄。
·解析动作主要针对类或接口,字段,类方法,接口方法,方法类型等。对应常量池中的CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info等。

解析是检查指定的类是否引用了其他的类/接口,是否能找到和加载其他的类/接口。这些检查将针对被引用的类/接口递归进行,JVM的实施也可以在后面阶段执行解析,即正在执行的代码真正要使用被引用的类/接口的时候。

初始化

·初始化阶段就是执行类构造器方法<Clinit>()的过程。
·此方式不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
·构造器方法中指令按语句中源文件中出现的顺序执行。
·<Clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init>())
·若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕。
·虚拟机必须保证一个类<clinit>()方法在多线程下被同步加锁。

在这最后一步中,JVM用赋值或者缺省值将静态变量初始化,初始化发生在执行main方法之前。在指定的类初始化前,会先初始化它的父类,此外,在初始化父类时,父类的父类也要这样初始化。这个过程是递归进行的。

简而言之,整个流程是将类存进内存中,检查类的对应调用的类和接口是否可正常使用,再对类进行初始化的过程。

 执行:

public class Test{
    public static int a=5;
    public static int b=a*2;

    static{
        System.out.println("static");
        System.out.println(b);
    }
    public static void main(String[] args){
        System.out.println("main method");

    }
}

>>

static
10
main method

 类加载器分类

JVM支持两种类型的类加载器,分别是引导类加载器(Bootstrap ClassLoadr)和自定义类加载器(User-Defined ClassLoader)。所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。扩展类加载器和系统类加载器属于自定义加载器。

 

 

 

这里的四者之间的关系是包含关系,不是上层下层,也不是父子类的继承关系。

 

 

 

启动类加载器(引导类加载器,Bootstarp ClassLoader)

·这个类加载使用c/c++语言实现,嵌套在JVM内部
·它用来加载java核心库(JAVA_HOME/jre/lib/rt.jar,resources.jar或sun.boot.class.path路径下的内容,用于提供JVM自身需要的类。
·并不继承自java.lang.ClassLoader,没有父加载类。
·加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
·出于安全考虑,Bootstrap启动器加载器只加载包名为java,javax,sun等开头的类

扩展类加载器(Extension ClassLoader)

·Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。
·派生于ClassLoader类
·父类加载器为启动类加载器
·从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

应用程序类加载器(系统类加载器,AppClassLoader)

·java语言编写,由sun.misc.launcher$AppClassLoader实现
·派生于ClassLoader类
·父类加载器为扩展类加载器
·它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
·该类加载是程序中默认的类加载器,一般来说,java应用的类都是由它来完成加载
·通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器

 运行:

public class Test{
    public static int a=5;
    public static int b=a*2;

    static{
        System.out.println("static");
        System.out.println(b);
    }
    public static void main(String[] args){
        ClassLoader classLoader1=Test.class.getClassLoader();
        ClassLoader classLoader2=classLoader1.getParent();
        ClassLoader classLoader3=classLoader2.getParent();
        ClassLoader classLoader4=String.class.getClassLoader();

        ClassLoader systemClassLoader=ClassLoader.getSystemClassLoader();
        System.out.println(classLoader1);
        System.out.println(classLoader2);
        System.out.println(classLoader3);
        System.out.println(classLoader4);
        System.out.println(systemClassLoader);

    }
}

>>

jdk.internal.loader.ClassLoaders$AppClassLoader@3fee733d
jdk.internal.loader.ClassLoaders$PlatformClassLoader@7cc355be
null
null
jdk.internal.loader.ClassLoaders$AppClassLoader@3fee733d

jvm8下可以实用下面的代码获得启动类加载器,扩展类加载器,以及应用类加载器的加载路径:

System.getProperty("sun.boot.class.path")
System.getProperty("java.ext.dirs")
System.getProperty("java.class.path")

 

Jdk1.9加入了模块化系统,对类加载机制也做出了调整。使用PlatformClassLoader代替ExtClassLoader

据说目前java8是主流,但我idea默认是jdk13,可选11,总不至于把版本降低为8吧?

 

~双亲委派机制

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

 

工作原理

(1)如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
(2)如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
(3)如果父类加载器可以完成加载任务,就成功返回,倘若父类加载器无法加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

优势

·避免类的重复加载
·保护程序安全,防止核心API被随意篡改
-自定义类型

~沙箱安全机制

自定义String类,在加载自定义String类的时候会率先实用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java\lang\String.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类型。这样可以保证对java核心源代码的保护,这就是沙箱安全机制。

~其他

在JVM中表示两个class对象是否为同一个类存在两个必要条件:
(1)类的完整类名必须一致,包括报名。
(2)加载这个类的classLoader(指classLoader实例对象)必须相同。
JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,jvm需要保证这两个类型的类加载器是相同的。

运行时数据区

 

 

 内存概述

内存是非常重要的系统资源,是硬盘和cpu的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请,分配,管理的策略,保证了JVM的高效稳定运行。不同的jvm对于内存的划分方式和管理机制存在着部分差异。结合jvm虚拟机规范,来探讨一下经典的jvm内存布局。

java虚拟机定义了若干程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应,这些线程对应的数据区域会随着线程的开始和结束而创建和销毁。
如下图所示,灰色的为单独线程私有,红色的为多个线程共享的。即:
·每个线程:独立包括程序计数器,栈,本地栈
·线程间共享:堆,堆外内存(永久代或元控件,代码缓存)

 

 

线程概述

·线程是一个程序里的运行单元。jvm允许一个应用有多个线程并行的执行。
·在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射。当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。
·操作系统负责所有线程的安排调度到任何一个可用的cpu上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。

如果你实用jconsole或者是任何一个调试工具,都能看到后台有许多线程在运行。这些后台线程不包括调用public static void main(Sring[])的main线程以及所有这个main线程自己创建的线程。
这些主要的后台系统线程在Hotspot JVM里主要是以下几个:
(1)虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才会变化。这种线程的执行类型包括“stop-the-world"的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。
(2)周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。
(3)GC线程:这种线程对于JVM里不同种类的垃圾收集行为提供了支持。
(4)编译线程:这种线程在运行时会将字节码编译成本地代码。
(5)信号调度线程:这种线程接受信号并发送给JVM,在它内部通过调用适当的方法进行处理。

程序计数器(PC寄存器)

 

概述

JVM中的程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。
这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也成为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。

作用:
PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。

程序计数器是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。
在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的java方法的jvm指令地址;或者,如果是在执行native方法,则是未指定值(undefined)。
它是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
它是唯一一个在java虚拟机规范中没有规定任何OutOtMemoryError情况的区域。

 PC寄存器的使用

写一段代码,查看反编译结果:

public class Test{

    public static void main(String[] args){
        int a=5;
        int b=7;
        int c=a+b;
        System.out.println(c);
    }
}

反编译结果如下:

 

 

虚拟机栈概述

虚拟机栈的出现背景

由于跨平台性的设计,Java指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为寄存器的。
栈式机构的优点是跨平台,指令集小,编译器容易实现;缺点是性能下降,实现同样的功能需要更多的指令。

内存中的栈和堆

栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放哪儿。

Java虚拟机栈是什么?

Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java调用。

Java虚拟机栈是线程私有的,生命周期与线程一致。其作用是主管Java程序的运行,它保存方法的局部变量(8种基本数据类型或对象的引用地址)、部分结果,并参与方法的调用和返回。

比如这样一段代码:

public class Test{
    public static void main(String[] args){
        Test test=new Test();
        test.fun2();
    }

    public void fun1(){
        int i=2;
        int j=4;
    }
    public void fun2(){
        int a=5;
        int b=8;
        fun1();
    }

}

执行主线程的时候,栈中数据如下:

 

 

Java虚拟机栈的特点

栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
JVM直接对Java栈的操作只有两个:
(1)每个方法执行,伴随着进栈(入栈、压栈)
(2)之行结束后的出栈工作
对栈来说不存在垃圾回收的问题。

 

虚拟机栈的常见异常

Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
(1)如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverFlowError异常。
(2)如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常。

比如执行:

public class Test{
    public static void main(String[] args){
        main(args);
    }
}

会出现错误 Exception in thread "main" java.lang.StackOverflowError

设置栈内存大小
我们可以实用参数 -Xss 选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度

 测试,运行:

public class Test{
    static int count=1;
    public static void main(String[] args){
        count++;
        System.out.println(count);
        main(args);
    }


}

可以观察到count为20068时出现异常。

在idea中设置栈内存的方法:

 

 

 

 设置好之后,再运行程序,发现count输出3525出现异常

栈的存储单元

·每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。
·在这个线程上正在执行的每个方法都有各自对应的一个栈帧。
·栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

栈的运行原理

·JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出/后进先出”原则。
·在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被成为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
·执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
·如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的栈帧。

·不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另一个线程的栈帧。
·如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。(如果当前栈帧出现异常,会把该异常抛给前一个栈帧
·Java方法有两种返回函数的方式,一种是正常的函数返回,实用return指令;另一种是抛出异常。不管使用那种方式,都会导致栈帧被弹出。

实例:

public class Test{

    public static void main(String[] args){
        Test test=new Test();
        test.method1();
    }
    public void method1(){
        System.out.println("method1开始执行..");
        method2();
        System.out.println("method1结束..");
    }
    public void method2(){
        System.out.println("method2开始执行..");
        method3();
        System.out.println("method2结束..");
    }
    public void method3(){
        System.out.println("method3开始执行..");
        System.out.println("method3结束..");
    }


}

>>

method1开始执行..
method2开始执行..
method3开始执行..
method3结束..
method2结束..
method1结束..

虚拟机栈帧的内部结构

每个栈帧中存储着:
(1)局部变量表(Local Variables)
(2)操作数栈(Oprand Stack)(或表达式栈)
(3)动态链接(Dynamic Linking)(或指向运动时常量池的方法引用)
(4)方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
(5)一些附加信息

 

 

 

局部变量表

·局部变量表也被称之为局部变量数组或本地变量表
·定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。
·由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
·局部变量表所需要的容量大小是在编译器确定下来的,并保存在方法的Code属性的maxinum local variables数据中。在方法运行期间是不会改变局部变量表的大小的。
为int,boolean也被转换为int,0表示false,1表示true。

·方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对于一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用它就会占用更多的栈空间,导致其嵌套调用次数就会减少。

·局部变量表中的标量只在当前调用中有效。在方法执行时,虚拟机通过使用局部变量表完成值到参数变量的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量也会随之销毁。

 举例:

public class Test{

    public static void main(String[] args){
        Test test=new Test();
        int a=5;
        int j=3;
        System.out.println(a);
    }
}

反编译结果如下:

 

 

 也可以通过jclasslib查看相关信息

安装jclass步骤,file->settings->plugins,搜索jclasslib,install,重启。选择java文件,编译,view->show bytecode with jclasslib

关于Slot的理解

·参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。
·局部变量表,最基本的存储单元是Slot(变量槽)。
·局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAdress类型的变量。
·在局部变量表里,32位以内的数据类型只占一个slot(包括returnAdress类型),64位的类型(Long和double)占两个slot。
byte、short、char在存储前被转换为int,boolean也呗转换为int,0为false,非0为true。

·JVM会为局部变量表中的每一个slot都分配一个访问索引,通过索引即可成功访问到局部变量表中指定的局部变量值。
·当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量会按照顺序被复制到局部变量表中的每一个Slot中。(按照变量声明的顺序)
·如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或double类型变量)
·如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。(在构造方法或者非静态方法可以引用this,静态方法不能引用this)

·栈帧中的局部变量中的槽位是可以重用的,如果一个局部变量过了其作用域,那么其作用域之后申明的新的局部变量就很有可能会重复用过期局部变量的槽位,从而达到节省资源的目的。

 

 

 其他

在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

 静态变量与局部变量的对比

变量按数据结构可分为基本数据类型和引用数据类型。

按照在类中的生命位置可以分为:成员变量和局部变量。

成员变量按是否被static修饰又可分为类变量和实例变量。成员变量在使用前,都经历过默认初始化赋值。其中类变量,在链接的prepare阶段被赋0值,在初始化阶段被显式赋值;而实例变量,随着对象的创建,会在堆空间中分配变量空间,并默认赋值。

局部变量在使用前,必须进行显式赋值,否则编译不通过。

操作数栈(Operand Stack)

·每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈(Expression Stack)。
·操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈/出栈。
·某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再将结果压入栈。比如执行复制、交换、求和等操作。

·如果被调用的方法带有返回值的话,其返回值会将被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
·操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检查阶段的数据流分析阶段要再次验证。
·另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。

·操作数栈,主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间。
·操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
·每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度的编译器就定义好了,保存在方法的Code属性中,为max_stack的值。
·栈中的任何一个元素都是可以任意的Java数据类型,32bit的类型占一个栈单位深度,64位的类型占两个栈单位深度。
·操作数栈并非采用访问索引的方式来进行数据访问,而是只能通过标准的入栈和出栈操作来完成一次数据访问。

动态链接(指向运行时常量池的方法引用)

·每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokeddynamic指令
·在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symblolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时候,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

~方法的调用

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。

·静态链接
将一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译器可知,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。

·动态链接
如果被调用的方法在编译器无法确定下来,也就是说,只能够在程序运行期被调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因为也被称之为动态链接。

对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

·早期绑定
早期绑定就是指被调用的目标方法如果在编译器可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。

·晚期绑定
如果被调用的方法在编译器无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也被称之为晚期绑定。

随着高级语言的横空出世,类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语言风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类编程语言具备多态特性,那么自然也就具备早起绑定和晚期绑定两种绑定方式。

Java中任何一个普通的方法其实都具备虚函数的特性,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在java程序中不希望某种方法拥有虚函数的特征时候,则可以使用关键字final来标记这个方法。

实例:

~虚方法和非虚方法

·非虚方法
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这种方法成为非虚方法。
静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。
可以理解为非虚方法,与方法的重写相抵触。
其他方法成为虚方法。

虚拟机中提供了以下几种方法调用指令:
·普通指令
(1)invokestatic:调用静态方法,解析阶段确定唯一方法版本
(2)invokesepcial:调用<init>方法、私有父类方法,解析阶段确定唯一方法版本
(3)invokevirtual:调用所有虚方法
(4)invokeinterface:调用接口方法
·动态调用指令
(5)invokedynamic:动态解析出需要调用的方法,然后执行

前四条指令固化在虚拟机内部,方法的调用不可认为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法成为非虚方法,其余的(final修饰的除外)称为虚方法。

虚方法表

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

在面对对象的编程中,会很频繁地使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(Virtual method table)(非虚方法不会出现在表中)来实现。实用索引表来代替查找。
每个类都有一个虚方法表,表中存放着各个方法的实际入口。
虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始化准备完成之后,JVM会把该类的方法表也初始化完毕。

 

 

方法返回地址

·存放调用该方法的pc寄存器的值
·一个方法的结束,有两种方式,即正常执行完成,或出现未处理的异常,非正常退出
无论通过那种方式退出,在方法退出后都返回该方法被调用的位置。方法正常退出时,调用者的pc计数器作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址要通过异常表来确定,栈帧中一般不会保存这部分的信息。

·当一个方法开始执行后,只有两种方式可以退出这个方法:
(1)执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用着,简称正常完成出口;
一个方法在正常调用完成之后究竟需要使用哪一种返回指令还需要根据方法返回值的实际数据类型而定。
在字节码指令中,返回指令包含ireturn(返回值是boolean、byte、char、short和int类型时使用),lreturn、freturn、dreturn、
areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类实例初始化方法、类和接口的初始化方法使用。

(2)在方法执行的过程中遇到异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法的退出。简称异常完成出口。
方法执行过程中抛出异常时的异常处理,存储在一个异常表,方便在发生异常的时候找到处理异常的代码。

上图表示,异常出现在字节码指令的4-6行,使用19行的指令处理异常

附加信息

栈帧中允许携带与java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。

本地方法栈(Native Method Stack)

 

~本地方法接口

 

简单来说,一个Native Method就是一个java调用非Java代码的接口。一个Native Method是这样一个java方法:该方法的实现由非Java语言实现,比如C。这个特征并非java所特有,很多其他的编程语言都有这一机制,比如在c++中,你可以用extern "c"告知C++编译器去调用一个C的函数。

"A native method is a Java method whose implementation is provided by no-java code."

在定义一个native method时,并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java函数在外面实现的。

本地接口的作用是融合不同的编程语言为java所用,它的初衷是融合C/C++程序。

·为什么要实用Native Method?
Java使用起来非常方便,然而有些层次的任务用Java实现起来非常不容易,或者我们对程序的效率很在意时,问题就来了。

·与Java环境外交互
有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解java应用之外的繁琐的细节。

本地方法栈

·Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。
·本地方法栈,也是线程私有的。
·允许被实现成固定或可动态扩展的内存大小。(在内存溢出方面是相同的)
·本地方法是实用C语言实现的。
·它的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库。
·当某个线程调用一个本地方法时,它就进入了一个全新的并且不受虚拟机限制的世界。它和虚拟机拥有同样的权限:
本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
它甚至可以使用本地处理器中的寄存器。
直接从本地内容的堆中分配任意数量的内存。

·并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。

·在Hostpot jVM中,直接将本地方法栈与虚拟机栈合二为一。

堆空间

 

 概述

·一个JVM实例只存在一个堆空间,堆也是Java内存管理的核心区域。
·Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。堆内存大小是可以调节的。
·《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
·所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buff,TLAB)。

·《Java虚拟机规范》中对Java堆的描述是:所有的对象实际以及数组都应当在运行时分配在堆上。从实际使用角度看的,应该是“几乎”所有的对象实例都分配在堆上。
·数组和对象可能永远不会存储在栈上,因为栈帧中保存应用,这个引用指向对象或数组在堆中的位置。

 

·在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
·堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

堆的内存细分

现代垃圾收集器大部分都基于分代收集理论涉及,堆空间细分为:

·在Java 7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区
Young Generation Space,新生区,又被划分为Eden区和Survivor区 Young/New
Tenure Generation Space,养老区 Old/Tenure
Permanent Space,永久区 Perm

·在Java 8及之后堆内存逻辑上分为三部分;新生区+养老区+元空间
Young Generation Space,新生区,又被划分为Eden区和Survivor区 Young/New
Tenure Generation Space,养老区 Old/Tenure
Meta Space,元空间 Meta

 

 

设置堆空间大小

(1)-Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
-X 是jvm的运行参数
ms 是memory start

(2)-Xmx 用来设置堆空间(年轻代+老年代)的最大内存大小
(3)默认堆空间大小
初始内存大小:物理电脑内存大小/64
最大内存大小:物理电脑内存/4

·在开发中建议将初始化堆内存和最大的堆内存设置成相同的值。

·查看设置的参数:jps/jstat -gc 进程id 或设置参数-XX:+PrintGCDetail

试运行代码:

public class Test{
    public static void main(String[] args){
        long initialMemory=Runtime.getRuntime().totalMemory()/1024/1024;
        long maxMemory=Runtime.getRuntime().maxMemory()/1024/1024;

        System.out.println("Xms:"+initialMemory);
        System.out.println("Xmx:"+maxMemory);

        System.out.println("SYSTEM MEMORY:"+initialMemory*64/1024+"G");
        System.out.println("SYSTEM MEMORY:"+maxMemory*4/1024+"G");
    }
}

堆空间OOM

测试代码:

public class Test{
    public static void main(String[] args) throws InterruptedException {
        ArrayList<Pic> list=new ArrayList();
        while (true){
            Thread.sleep(20);
            list.add(new Pic(new Random().nextInt(1024*1024)));
        }

    }

    static class Pic{
        private byte[] pixs;

        public Pic(int length){
            this.pixs=new byte[length];
        }

    }
}

异常信息:Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

可以使用jvisualvm查看相关信息

 

参考

这大概是一篇最简单清晰的Java JVM执行流程

BiBi - JVM -8- 类加载机制

Java虚拟机9:Java类加载机制

Java 9对类加载器的改动

posted @ 2020-09-14 21:22  vocus  阅读(380)  评论(0)    收藏  举报