JVM 是 Java Virtual Machine 的缩写,它是一个虚构出来的计算机,类似于小电脑运行在 Windows 或 Linux 这些系统上,它与系统直接交互,但不与硬件直接交互。

jvm

Java 文件是如何被运行的

一个.java 文件需要被编译成.class 文件,在将其装进一个类加载器中,它就像搬运工,把所有.class 文件搬进 JVM 中,JVM 简易结构如下:

image-20210521191618691

JVM模型架构

Java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构

基于栈式架构的特点

  • 设计和实现更简单,适用于资源受限的系统

  • 避开了寄存器的分配难题:使用零地址指令方式分配

  • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现

  • 不需要硬件支持,可移植性更好,更好实现跨平台

基于寄存器架构的特点

  • 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机

  • 指令集架构则完全依赖硬件,可移植性差

  • 性能优秀和执行更高效

  • 花费更少的指令去完成一项操作

  • 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主

总结

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

时至今日,尽管嵌入式平台已经不是Java程序的主流运行平台了(准确来说应该是HotSpotVM的宿主环境已经不局限于嵌入式平台了),那么为什么不将架构更换为基于寄存器的架构呢?答:已经够用了

栈:跨平台性、指令集小、指令多:执行性能比寄存器差

JVM生命周期

虚拟机的启动

Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。

虚拟机的执行

  • 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序。

  • 程序开始执行时他才运行,程序结束时他就停止。

  • 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程。

虚拟机的退出

有如下的几种情况:

  • 程序正常执行结束

  • 程序在执行过程中遇到了异常或错误而异常终止

  • 由于操作系统用现错误而导致Java虚拟机进程终止

  • 某线程调用Runtime类或system类的exit方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作。

除此之外,JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载 Java虚拟机时,Java虚拟机的退出情况。

类加载器

image-20210521192308952

类加载器只负责class文件的加载,至于它是否可以运行,则由Execution Engine(执行引擎)决定。

加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

说白了,类加载器会把类的详细详细加载到JVM中

类加载的过程

加载阶段

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

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

  3. 内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

补充:加载class文件的方式

  • 从本地系统中直接加载

  • 通过网络获取,典型场景:Web Applet

  • 从zip压缩包中读取,成为日后jar、war格式的基础

  • 运行时计算生成,使用最多的是:动态代理技术

  • 由其他文件生成,典型场景:JSP应用

  • 从专有数据库中提取.class文件,比较少见

  • 从加密文件中获取,典型的防Class文件被反编译的保护措施

链接阶段

  1. 验证(Verify

    • 判断Class文件的字节流是否符合当前虚拟机的规范,保证类的正确性

    • 主要有四种方式:文件格式验证、元数据验证、字节码验证、符号引用验证。

      去查看Class文件的二进制字节码时,你会发现开头几个都是CA FE BA BE这就是一个验证

  2. 准备(Prepare

    • 会为类中的变量分配内存,并设置初始值,即为0
    • 不包含用final修饰的static变量,因为final在编译时就分配了,准备阶段会显示初始化
    • 不会为实例变量初始化,因为实例变量是在对象实例化时随着对象一起分配在java堆中的
  3. 解析(Resolve

    • 将常量池内的符号引用转换为直接引用的过程。

    • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。

    • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

    • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info,CONSTANT_Fieldref_info、CONSTANT_Methodref_info等。

初始化阶段

  • 初始化阶段就是执行类构造器方法()的过程。此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来,构造器方法中指令按语句在源文件中出现的顺序执行。ps:方法只在拥有静态变量或静态代码块时才有

  • ()不同于类的构造器(构造方法)。(关联:构造器是虚拟机视角下的())

  • 若该类具有父类,JVM会保证子类的()执行前,父类的()已经执行完毕。

  • 虚拟机必须保证一个类的()方法在多线程下被同步加锁。(说白了,如果在中如果有死循环之类的,会卡住,多个线程new这个对象的话无法正常运行)

类加载器的加载顺序 从下往上

  1. BootStrap ClassLoader(引导类加载器):rt.jar
  2. Extention ClassLoader(扩展类加载器): 加载扩展的 jar 包
  3. App ClassLoader(系统类加载器):指定的 classpath 下面的 jar 包
  4. Custom ClassLoader:自定义的类加载器

引导类加载器

  • 这个类加载使用**C/C++**语言实现的,嵌套在JVM内部。

  • 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类

  • 并不继承自ava.lang.ClassLoader,没有父加载器。

  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。

  • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

扩展类加载器

  • Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。

  • 派生于ClassLoader类

  • 父类加载器为启动类加载器(这不是继承,是指它由引导类加载器加载)

  • 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

系统类加载器

  • java语言编写,由sun.misc.LaunchersAppClassLoader实现

  • 派生于ClassLoader类

  • 父类加载器为扩展类加载器(这不是继承,是指它由引导类加载器加载)

  • 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库

  • 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载

  • 通过ClassLoader.getSystemclassLoader() 方法可以获取到该类加载器

自定义类加载器

在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。 为什么要自定义类加载器?

  • 隔离加载类

  • 修改类加载的方式

  • 扩展加载源

  • 防止源码泄漏

用户自定义类加载器实现步骤:

  1. 开发人员可以通过继承抽象类ava.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求

  2. 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass() 方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadclass() 方法,而是建议把自定义的类加载逻辑写在findClass()方法中

  3. 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。

一般情况下了解一下就行了

双亲委派机制

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

工作原理

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

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

  • 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

image-20210524185617806

优势

  • 避免类的重复加载
  • 保护程序安全,防止核心API被随意篡改
    • 自定义类:java.lang.String
    • 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang开头的类)

沙箱安全机制

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

Java 安全模型的核心就是 Java 沙箱,什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并严格限制代码对本地系统资源的访问,通过这样的措施来保证对代码的隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问,那系统资源包括什么?——CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。
详情点击

JVM运行时数据区

我们通过磁盘或者网络IO得到的数据,都需要先加载到内存中,然后CPU从内存中获取数据进行读取,也就是说内存充当了CPU和磁盘之间的桥梁

  • 每个线程:独立包括程序计数器、栈、本地方法栈。
  • 线程间共享(进程):堆、堆外内存(永久代或元空间、代码缓存)

线程

线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行。 在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射。

当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也 会回收。

操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。

如果你使用console或者是任何一个调试工具,都能看到在后台有许多线程在运行。这些后台线程不包括调用public static void main(String[] args)的main线程以及所有这个main线程自己创建的线程。

这些主要的后台系统线程在Hotspot JVM里主要是以下几个:

  • 虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括”stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。

  • 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。

  • GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持。

  • 编译线程:这种线程在运行时会将字节码编译成到本地代码。

  • 信号调度线程:这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理

程序计数器

程序计数器:Program counter register

也可以叫PC寄存器

每个线程都有一个程序计数器,是线程私有的,就是一直指针,指向下一条指令的地址(指令地址/偏移地址),也就是即将执行的指令,在执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。

任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined)。

它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

字节码解释器工时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

它是唯一一个在Java虚拟机规范中没有规定任何OutofMemoryError情况的区域。

常见面试问题

使用PC寄存器储存字节码指令地址有什么用?

为什么使用PC寄存器记录当前线程的执行地址?

因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。

JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。


PC寄存器为什么被设置为线程私有的?

我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。

这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

JAVA栈

由于跨平台性的设计,Java的指令都是根据栈来设计的,不同平台CPU架构不同,所以不能设计为基于寄存器。

优点:跨平台、指令集小、编译器容易实现

缺点:性能下降、实现同样的功能需要更多指令

栈是运行时的单位,而堆是存储的单位

  • 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。

  • 堆解决的是数据存储的问题,即数据怎么放,放哪里

每个线程都会创建一个Java栈,其内部保存一个个栈帧,对应着一次次Java方法的调用

线程创建的时候会创建,生命周期和线程生命周期一样,线程结束栈也就释放了,描述的是 Java 方法执行的内存模型:每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。

主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

JVM对Java栈只有进栈出栈两个操作,不存在垃圾回收

对于栈来说不存在垃圾回收问题(栈存在溢出的情况)

栈中可能出现的异常

Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError 异常。

  • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutOfMemoryError 异常。

设置栈内存大小

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

Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。

栈帧的内部结构

每个栈帧中存储着:

  • 局部变量表(Local Variables)

  • 操作数栈(operand Stack)(或表达式栈)

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

  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)

  • 一些附加信息

image-20210530194727337
局部变量表(Local Variables)

局部变量表也被称之为局部变量数组或本地变量表

  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。

  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题

  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

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

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

关于Solt的理解

  • 在局部变量表中最基本的存储单元是Slot(变量槽)

  • 参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。

  • 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。

  • 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。

    • byte、short、char 在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true。
    • long和double则占据两个solt
  • JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值

  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上

  • 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或doub1e类型变量)

  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。

image-20210601194800016

在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。

局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

操作数栈(Operand Stack)

每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈(Expression Stack)

操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)

  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈

  • 比如:执行复制、交换、求和等操作

image-20210602190739279

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。

每一操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值。

栈中的任何一个元素都是可以任意的Java数据类型

  • 32bit的类型占用一个栈单位深度

  • 64bit的类型占用两个栈单位深度

操作数栈并非采用访问索引的方式来进行数据访问的(虽然操作数栈是通过数组实现的),而是只能通过标准的入栈和出栈操作来完成一次数据访问

如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。

详情图文

动态链接(DynamicLinking)

动态链接、方法返回地址、附加信息 : 有些地方被称为帧数据区

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic指令

在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

image-20210603203546783

为什么需要运行时常量池呢?

常量池的作用:就是为了提供一些符号和常量,便于指令的识别

方法返回地址(Return Address)

存放调用该方法的pc寄存器的值。一个方法的结束,有两种方式:

  • 正常执行完成

  • 出现未处理的异常,非正常退出

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

当一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口
    • 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
    • 在字节码指令中,返回指令包含ireturn(当返回值是boolean,byte,char,short和int类型时使用),lreturn(Long类型),freturn(Float类型),dreturn(Double类型),areturn。另外还有一个return指令声明为void的方法,实例初始化方法,类和接口的初始化方法使用
  1. 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口

方法执行过程中,抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码

Exception table:
from to target type
4    16   19   any
19   21   19   any

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

一些附加信息

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

栈面试题

  • 举例栈溢出的情况?(StackOverflowError)->方法调用过多,堆满了栈空间

    • 通过 -Xss设置栈的大小
  • 调整栈大小,就能保证不出现溢出么?

    • 不能保证不溢出
  • 分配的栈内存越大越好么?

    • 不是,一定时间内降低了OOM概率,但是会挤占其它的线程空间,因为整个空间是有限的。
  • 垃圾回收是否涉及到虚拟机栈?

    • 不会
  • 方法中定义的局部变量是否线程安全?->看实际情况

    • 具体问题具体分析。如果对象是在内部产生,并在内部消亡,没有返回到外部,那么它就是线程安全的,反之则是线程不安全的。
运行时数据区 是否存在Error 是否存在GC
程序计数器
虚拟机栈 是(SOE)
本地方法栈
方法区 是(OOM)

本地方法接口

本地方法本地接口 简称 JNI:Java Native Interface

简单地讲,一个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 non-java code.

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

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

举例

public class IHaveNatives{
    public native void methodNative1(int x);
    public native static long methodNative2();
    private native synchronized float methodNative3(Object o);
    native void methodNative4(int[] ary) throws Exception;
}

标识符native可以与其它java标识符连用,但是abstract除外

为什么使用Native Method?

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

与Java环境的交互

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

与操作系统的交互

JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用c写的。还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。

Sun’s Java

Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分是用Java实现的,它也通过一些本地方法与外界交互。例如:类java.lang.Thread的setPriority()方法是用Java实现的,但是它实现调用的是该类里的本地方法setPriority()。这个本地方法是用C实现的,并被植入JVM内部,在Windows 95的平台上,这个本地方法最终将调用Win32 setPriority() ApI。这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVw调用。

现状

目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等等,不多做介绍。

本地方法栈

Java虚拟机栈于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。

本地方法栈,也是线程私有的。

允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)

  • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError 异常。

  • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常。

本地方法是使用C语言实现的。

它的具体做法是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库。

image-20210609162116830

当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区

  • 它甚至可以直接使用本地处理器中的寄存器

  • 直接从本地内存的堆中分配任意数量的内存。

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

在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。

Heap,一个 JVM 只有一个堆内存,堆内存的大小是可以调节的。

Java虚拟机用来存储对象实例用的,在我们开发中,只要通过new创建的对象都分配在堆中,注意堆中的对象内存需要等待GC回收,也是Java虚拟机线程共享区域,内部会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

类加载器读取了类文件后,一般会把 类、方法、常量、变量、保存我们所有引用类型的真实对象

堆内存中还要细分为三个区域:

  • 新生区(伊甸园区)
  • 养老区
  • 元空间/永久区

image-20210610164158428

GC 垃圾回收,主要是在伊甸园和养老区

假设内存满了,OOM,堆内存不够

JDK 8 中永久区向元空间的转换的原因:

  • 1、字符串存在永久代中,容易出现性能问题和内存溢出。
  • 2、类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  • 3、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
  • 4、Oracle 可能会将 HotSpot 与 JRockit 合二为一。

设置堆空间大小

Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项”-Xmx”和”-Xms”来进行设置。

  • “-Xms”用于表示堆区的起始内存,等价于-XX:InitialHeapSize

    • -X 是jvm的运行参数
    • ms 是memory start
  • “-Xmx”则用于表示堆区的最大内存,等价于-XX:MaxHeapSize

一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutOfMemoryError异常。

通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在ava垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。

默认情况下

  • 初始内存大小:物理电脑内存大小 / 64

  • 最大内存大小:物理电脑内存大小 / 4


存储在JVM中的Java对象可以被划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速

  • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致

Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)

其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)

image-20210617165007381

配置新生代与老年代在堆结构的占比。

  • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3

  • 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5

在HotSpot中,Eden空间和另外两个survivor空间所占的比例是8:1:1

但是实际情况下,不会严格按照8:1:1进行分配,它会有一个自适应的过程

当然开发人员可以通过选项-xx:SurvivorRatio调整这个空间比例。比如-xx:SurvivorRatio=8,这样调整的话就会严格执行8:1:1

几乎所有的Java对象都是在Eden区被new出来的。绝大部分的Java对象的销毁都在新生代进行了。

  • IBM公司专门研究表明,新生代中80%的对象都是“朝生夕死”的。

可以使用选项-Xmn设置新生代最大内存大小,这个参数一般使用默认值就可以了。

对象分配过程

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。

  1. new的对象先放伊甸园区。此区有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
  3. 然后将伊甸园中的剩余对象移动到幸存者0区。
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
  6. 啥时候能去养老区呢?可以设置次数。默认是15次。
    • 可以设置参数:进行设置-Xx:MaxTenuringThreshold= N
  7. 在养老区,相对悠闲。当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理
  8. 若养老区执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。

image-20210621151244568

总结:针对幸存者 s0,s1 区:复制之后有交换,谁空谁是 to。

关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不再永久区/元空间收集。

注意:只有伊甸园满了才会触轻 GC,而幸存者区满了是绝对不会触发轻 GC 的。

Minor GC,MajorGC,Full GC

JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。

针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(FullGC)

  • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:

    • 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC / Old GC):只是老年代的圾收集。
      • 目前,只有CMSGC会有单独收集老年代的行为。
      • 注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
    • 混合收集(MixedGC):收集整个新生代以及部分老年代的垃圾收集。
      • 目前,只有G1 GC会有这种行为
  • 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。

永久区

这个区域常驻内存的,用来存放 jdk 自身携带的 Class 对象,interfac 元数据,存储的是 java 运行时的环境,这个区域不存在垃圾回收,关闭 vm 虚拟机的时候会释放这个区域的内存。

如果一个启动类,加载了大量第三方 jar 包,tomcat 部署了太多的应用,大量动态生成的反射类,不断的被加载,直到内存满,就会出现 OOM

  • jdk1.6 之前:永久代,常量池在方法区
  • jdk1.7:永久代,但是慢慢退化,去永久代,常量池在堆中
  • jdk1.8 之后:无永久代,常量池在元空间

元空间在逻辑上存在,物理上不存在(也可以说它存在于本地内存,不占用堆内存)

总结

//vm常用参数 可以调整堆内存 和 打印信息
-XX:+PrintFlagsInitial 查看所有参数的默认初始值
-XX:+PrintFlagsFinal 查看所有参数的最终值
-Xms 设置初始化内存分配大小 默认1/64
-Xmx 设置最大分配内存 默认1/4
-Xmn 设置新生代的大小
-XX:NewRatio 设置新生代和老年代在堆中的结构比例
-XX:SurvivorRatio 设置新生代中Eden和s0/s1空间比例
-XX:MaxTenuringThreshold 设置新生代垃圾的最大年龄
-XX:+PrintGCDetails 打印GC垃圾回收信息
//打印GC简要信息
    1. -XX:+PrintGC
    2. -verbose:gc
-XX:HandlePromotionFalilure//是否设置空间分配担保
-Xms8m -Xmx8m -XX:+PrintGCDetails

空间分配担保策略

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

  • 如果大于,则此次Minor GC是安全的

  • 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允担保失败。

    • 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
      • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
      • 如果小于,则改为进行一次Full GC。
    • 如果HandlePromotionFailure=false,则改为进行一次Full Gc。

在JDK6 Update24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察openJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行FullGC。

逃逸分析:代码优化

使用逃逸分析,编译器可以对代码做如下优化:

栈上分配

将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配

JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。

常见的栈上分配的场景

在逃逸分析中,已经说明了。分别是给成员变量赋值、方法返回值、实例引用传递。

同步省略

如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

线程同步的代价是相当高的,同步的后果是降低并发性和性能。

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除

分离对象/标量替换

有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。

相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

举例

public static void main(String args[]) {
    alloc();
}
private static void alloc() {
    Point point = new Point(1,2);
    System.out.println("point.x" + point.x + ";point.y" + point.y);
}
class Point {
    private int x;
    private int y;
}

以上代码,经过标量替换后,就会变成

private static void alloc() {
    int x = 1;
    int y = 2;
    System.out.println("point.x = " + x + "; point.y=" + y);
}

可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个标量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。 标量替换为栈上分配提供了很好的基础。

标量替换参数设置

参数-XX:EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配到栈上。

总结

关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟。

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段

注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。据我所知,Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。

多书籍还是基于JDK7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。

方法区

Method area 方法区

《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。

所以,方法区看作是一块独立于Java堆的内存空间

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。

  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。

  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。

  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError: PermGen space 或者java.lang.OutOfMemoryError: Metaspace

    • 加载大量的第三方的jar包;Tomcat部署的工程过多(30~50个);大量动态的生成反射类
  • 关闭JVM就会释放这个区域的内存。

在jdk7及以前,习惯上把方法区,称为永久代。到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存

设置方法区大小

方法区的大小不是固定的,jvm可以根据需求动态调整.

jdk7及以前

  • 通过来设置永久代初始分配空间。默认值是20.75M-XX:Permsize

  • 通过来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M-XX:MaxPermsize

  • 当JVM加载的类信息容量超过了这个值,会报异常OutOfMemoryError:PermGen space

jdk8以后

  • 元数据区大小可以使用参数 -XX:MetaspaceSize-XX:MaxMetaspaceSize指定

  • 默认值依赖于平台。windows下,-XX:MetaspaceSize=21M -XX:MaxMetaspaceSize=-1//即没有限制

  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace

  • -XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。

  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。

方法区的内部结构

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载 的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

image-20210628165900927

类信息

对于每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储一下类型信息:

  1. 类型的完整有效名称(包名.类名)
  2. 类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)
  3. 类型的修饰符(public,abstract,final的某个子集)
  4. 类型直接接口的一个有序列表

域(Field)信息

JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序

域的相关信息包括: 域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)

方法(Method)信息

JVM必须保存所有方法的一下信息,同域信息一样包括声明顺序:

  1. 方法名称
  2. 方法返回类型(或void)
  3. 方法参数的数量和类型(按顺序)
  4. 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
  5. 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
  6. 异常表(abstract和native方法除外)
    • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

non-final的类变量

  • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分

  • 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它

public class MethodAreaTest {
    public static void main(String[] args) {
        Order order = new Order();
        order.hello();
        System.out.println(order.count);
    }
}
class Order {
    public static int count = 1;
    public static void hello() {
        System.out.println("hello!");
    }
}

补充说明:全局常量(static final)

被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。

运行时常量池/常量池

  • 方法区,内部包含了运行时常量池

  • 字节码文件,内部包含了常量池

  • 要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。

  • 要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。

官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html

image-20210701112630282

常量池

一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。

主要包含数据类型为:

  • 数量值
  • 字符串值
  • 类引用
  • 字段引用
  • 方法引用

总结:常量池、可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型

运行时常量池
  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。

  • 常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用这部分内容将在类加载后存放到方法区的运行时常量池中。

  • 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。

  • JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。

  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址

  • 运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性

  • 运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些。

  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。

方法区的演化

JDK1.6及之前 有永久代(Permanent Generation),静态变量存放在永久代上
JDK1.7 有永久代,但已经逐步’去永久代’,字符串常量池、静态变量移除,保存在堆中
JDK1.8及之后 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆
image-20210702171013532 image-20210702171022383 image-20210702171030978

方法区的垃圾回收

有些人认为方法区(如Hotspot虚拟机中的元空间或者永久代)是没有垃圾回收的,非也,《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的zGC收集器就不支持类卸载).

一般来说方法区中的回收效果难以令人满意,尤其是卸载类型,条件苛刻,但是这区域的回收有时又是必要的.以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏.

方法区的垃圾收集只要针对两部分: 常量池中的废弃常量和不在使用的类型.

先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:

  • 类和接口的全限定名

  • 字段的名称和描述符

  • 方法的名称和描述符

HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收

回收废弃常量和回收Java堆中的对象非常相似.

判断常量是否”废弃”相对简单,但是要判定应该类型是否属于”不在被使用的类”的条件就十分苛刻,必须同时满足下列三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机允许对满足上述三个条件的无用类进行回收,也止步于”被允许”,而不是像对象一样,没有引用就必然被回收,关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class 以及 -XX:+TraceClassLoading-XX:+TraceClassUnLoading查看类加载和卸载信息

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

常见面试题

百度:

说一下JVM内存模型吧,有哪些区?分别干什么的?

蚂蚁金服:

Java8的内存分代改进 JVM内存分哪几个区,每个区的作用是什么?

一面:JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区?

二面:Eden和survior的比例分配

小米:

jvm内存分区,为什么要有新生代和老年代

字节跳动:

二面:Java的内存分区

二面:讲讲jvm运行时数据库区 什么时候对象会进入老年代?

京东:

JVM的内存结构,Eden和Survivor比例。

JVM内存为什么要分成新生代,老年代,持久代。

新生代中为什么要分为Eden和survivor。

天猫:

一面:Jvm内存模型以及分区,需要详细到每个区放什么。

一面:JVM的内存模型,Java8做了什么改

拼多多:

JVM内存分哪几个区,每个区的作用是什么?

美团:

java内存分配 jvm的永久代中会发生垃圾回收吗?

一面:jvm内存分区,为什么要有新生代和老年代?

对象实例化

创建对象的方式

  • new:最常见的方式、Xxx的静态方法,XxxBuilder/XxxFactory的静态方法

  • Class的newInstance方法:反射的方式,只能调用空参的构造器,权限必须是public

  • Constructor的newInstance(XXX):反射的方式,可以调用空参、带参的构造器,权限没有要求

  • 使用clone():不调用任何的构造器,要求当前的类需要实现Cloneable接口,实现clone()

  • 使用序列化:从文件中、从网络中获取一个对象的二进制流

  • 第三方库 Objenesis

创建对象的步骤

  1. 判断对象对应的类是否加载、链接、初始化

    虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化(即判断类元信息是否存在)。

    如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader + 包名 + 类名为key进行查找对应的 .class文件;

    • 如果没有找到文件,则抛出ClassNotFoundException异常

    • 如果找到,则进行类加载,并生成对应的Class对象

  2. 为对象分配内存

    首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小

    如果内存规整:虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存。

    • 意思是所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针指向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial ,ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带Compact(整理)过程的收集器时,使用指针碰撞。

    如果内存不规整:虚拟机需要维护一个空闲列表(Free List)来为对象分配内存。

    • 已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。意思是虚拟机维护了一个列表,记录上那些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。

      选择哪种分配方式由Java堆是否规整所决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

  3. 处理并发问题

    • 采用CAS失败重试、区域加锁保证更新的原子性

    • 每个线程预先分配一块TLAB:通过设置 -XX:+UseTLAB参数来设定

  4. 初始化分配到的内存

    所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用

  5. 设置对象的对象头

    将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。

  6. 执行init方法进行实例化

    在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

    因此一般来说(由字节码中跟随invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。

给对象属性赋值的操作

  • 属性的默认初始化

  • 显式初始化

  • 代码块中初始化

  • 构造器中初始化

对象内存布局

  • 消息头(Header) - PS:如果是数组,还需记录数组的长度
    • 运行时元数据(Mark Word)
      • 哈希值(HashCode)
      • GC分代年龄
      • 锁状态标志
      • 线程持有的锁
      • 偏向线程ID
      • 偏向时间戳
    • 类型指针 - 执行类元数据InstanceKlass,确定该对象所属的类型
  • 实例数据(Instance Data)
    • 说明 - 它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)
    • 规则
      • 相同宽度的字段总是被分配在一起
      • 父类中定义的变量会出现在子类之前
      • 如果CompactFields参数为true(默认为true): 子类的窄变量可能插入到父类变量的空袭
  • 对其填充(Padding)
    • 不是必须的,也没有特别含义,仅仅起到占位符的作用

image-20210710142633579

对象访问定位

Java虚拟机规范中并没有定义应该通过什么方法去定位/访问到堆中对象的具体位置,所以对方访问方式是由虚拟机实现而定,主流有两种方式访问:句柄直接指针

如果使用句柄访问,在Java堆中就需要划分一块内存作为句柄池,栈帧中局部变量表中的对象存储句柄地址,而句柄中包含了对象的对象实例和类型数据的地址信息.

image-20210713174746007

如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,栈帧中局部变量表中的对象直接存储对象地址,如果访问对象本身的话,就不需要多一次间接访问的开销.

image-20210713175055319

这两种方式各有各的优点.

使用句柄的话最大的好处是对象存储的是稳定的句柄地址,在对象被移动时(垃圾收集时移动对象是非常普遍的行为)只改变句柄中的实例数据指针.

使用直接指针最大的好处是速度更快,节省了一次指针定位的时间开销,由于对象访 问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本.

对于HotSpot而言,它使用第二种方式进行对象访问(有例外情况,如果使用了Shenandoah收集器的 话也会有一次额外的转发),但纵观整个软件开发行业,各种语言,隔着框架中,使用句柄是十分常见的.

执行引擎

执行引擎是Java虚拟机组成的核心部分之一,属于JVM的下层,里面包括解释器即时编译器垃圾回收器.

“虚拟机”有一个相对的概念叫”物理机”,它们都有代码执行的能力,区别在于,”物理机”的执行引擎直接建立在处理器、缓存、指令集和操作系统层面.

而**”虚拟机”的执行引擎则是由软件自行实现的**,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式.

JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。

image-20210810144847605

执行引擎(Execution Engine)的任务是将字节码指令解释/编译为对应平台上的本地机器指令,可以简单的理解为一个翻译官的角色.\

Java代码编译和执行过程

image-20210810150530151

大部分部分程序代码转换为 物理机代码 或者 虚拟机代码 能够执行的指令集之前,都需要经过上图的步骤.

Java代码的编译是由Java源码编译器(前端编译器)来完成的,流程如下:

image-20210810150738990

Java字节码的执行是由JVM执行引擎(后端编译器)来完成的,如图:

image-20210810150827740

解释器: 当Java虚拟机启动时会根据预定义的规范对字节码文件采用逐行解释的方式执行,将字节码文件中的内容”翻译”为对应平台的本地机器执行指令.

JIT(Just In Time Compiler)编译器: 虚拟机将源代码直接编译成和本地机器平台相关的机器语言

JDK1.0时代,将Java语言定义为”解释执行”还是比较准确的,后来,Java也发展出可以直接生成本地代码的编译器,现在JVM在执行Java代码的时候,通常都会将 “解释执行” 与 “编译执行” 二者结合运行

image-20210810161244545

语言的演化

机器码

各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。

机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。

用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快。

机器指令与CPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同。

指令

由于机器码是有0和1组成的二进制序列,可读性实在太差,于是人们发明了指令。

指令就是把机器码中特定的0和1序列,简化成对应的指令(一般为英文简写,如mov,inc等),可读性稍好

由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。****

指令集

不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。 如常见的

  • x86指令集,对应的是x86架构的平台

  • ARM指令集,对应的是ARM架构的平台

汇编语言

由于指令的可读性还是太差,于是人们又发明了汇编语言。

在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用<mark地址符号(Symbol)或标号(Label)代替指令或操作数的地址。在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。

由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。

高级语言

为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言

当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。

image-20210810161717512

高级语言也不是直接翻译成机器指令,而是翻译成汇编语言码.

解释器

JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。

解释器工作机制

解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。

当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。

解释器分类

在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。

  • 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。

  • 而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。

在HotSpot VM,解释器主要由Interpreter模块和Code模块构成。

  • Interpreter模块:实现了解释器的核心功能

  • Code模块:用于管理HotSpot VM在运行时生成的本地机器指令

现状

由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python、Perl、Ruby等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些C/C++程序员所调侃。

为了解决这个问题,JVM平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。

不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。

JIT编译器


三种 JVM

  • Sun 公司 HotSopt,也就是我们常用的
  • BEA JRockit 一般用于前端商务和军事应用
  • IBM J9VM 一般 IBM 硬件独占

GC 题目:

  • JVM 的内存模型和分区,详细到每个分区放什么?
  • 堆里面的分区有那些?Eden,from,to,老年区,说说他们的特点
  • GC 的算法有那些?标记清除法,标记整理法,复制算法,引用计数法,怎么用的?
  • 轻 GC 和重 GC 分别在什么时候发生?

引用计数法(很少用):

image-20210316160850089

复制算法:

好处:没用内存碎片

坏处:浪费内存空间,多了一半空间永远是空的

复制算法最佳使用场景:对象存活度较低的时候(也就是新生区

image-20210316162625420

标记清除法:

标记/清除算法的基本思想就跟它的名字一样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

标记阶段:标记的过程其实就是前面介绍的可达性分析算法的过程,遍历所有的 GC Roots 对象,对从 GC Roots 对象可达的对象都打上一个标识,一般是在对象的 header 中,将其记录为可达对象;

清除阶段:清除的过程是对堆内存进行遍历,如果发现某个对象没有被标记为可达对象(通过读取对象 header 信息),则将其回收。

不足:

  • 标记和清除过程效率都不高
  • 会产生大量碎片,内存碎片过多可能导致无法给大对象分配内存。

image-20210316163301203

标记整理法:

标记—整理算法和标记—清除算法一样,但是标记—整理算法不是把存活对象复制到另一块内存,而是把存活对象往内存的一端移动,然后直接回收边界以外的内存,因此其不会产生内存碎片。标记—整理算法提高了内存的利用率,并且它适合在收集对象存活时间较长的老年代。

不足:

效率不高,不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上不如复制算法。

image-20210316164332825

STW机制

JVM在执行CG回收或其他特殊任务时,会挂起除了JVM之外所有JAVA应用程序线程,表现为全局停顿,所有JAVA代码停止,native代码可以执行,但不能于JVM交互

垃圾收集器

总结

内存效率:复制算法>标记清除算法>标记整理法 (时间复杂度)

内存整齐度:复制算法=标记整理法>标记清除算法

内存利用率:标记整理法=标记清除法>复制算法

参考

详细笔记:https://www.yuque.com/u21195183/jvm/

STW机制:https://www.csdn.net/tags/NtTakgwsMTEwODUtYmxvZwO0O0OO0O0O.html

JVM:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html