Java基础-JVM

JVM,Java Virtual Machine(Java虚拟机)的缩写,是 Java 虚拟机,是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM是整个 Java 实现跨平台的最核心的部分,能够运行以Java 语言写作的软件程序。所有的 Java 程序会首先被编译为 .class 的类文件,这种类文件可以在虚拟机上执行。

JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。大致流程如下:

graph TD
    A[Java源码 .java文件] -->|编码| B(字节码 .class文件) -->|编码| C[机器码 指令集]

JVM的构成

Java虚拟机主要由3大部分构成:运行时数据区、类加载器和执行引擎。

  1. 运行时数据区(Run-Time Data Areas),也就是JVM内存模型,主要由堆内存、方法区、程序计数器、虚拟机栈和本地方法栈组成,其中,堆和方法区是所有线程共有的,而虚拟机栈,本地方法栈和程序计数器则是线程私有的;
  2. 类加载器(Class Loader)负责加载字节码文件,即java编译后的 .class 文件;
  3. 执行引擎(Execution Engine) 的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。

运行时数据区

栈 Stack

虚拟机栈与本地方法栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的native方法(非Java)服务。

  • 虚拟机栈 VM Stack:虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表(局部变量表需要的内存在编译期间就确定了所以在方法运行期间不会改变大小),操作数栈,动态链接,方法出口等信息。每一个方法从调用至出栈的过程,就对应着栈帧在虚拟机中从入栈到出栈的过程。
  • 本地方法栈 Native Method Stacks:本地方法栈用于存储本地方法的局部变量表,本地方法的操作数栈等信息。当栈内的数据在超出其作用域后,会被自动释放掉。本地方法栈是在程序调用或JVM调用本地方法接口(Native)时候启用。

虚拟机栈由一帧帧的栈帧组成,而栈帧包含:局部变量表、操作栈等子栈。每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表(局部变量表需要的内存在编译期间就确定了所以在方法运行期间不会改变大小),操作数栈,动态链接,方法出口等信息。每一个方法从调用至出栈的过程,就对应着栈帧在虚拟机中从入栈到出栈的过程。

每个线程包含一个栈区,栈中只保存基本数据类型的对象和自定义对象的引用,即:栈存放的是引用/地址,也可以理解成对象的名称。

堆 Heap

一个JVM实例只有一个堆内存,所以JVM中所有的线程共享一个堆。堆是Java内存管理的核心区域,在JVM启动的时候创建,空间大小在创建时就已经确定下来,是JVM中最大的一块内存空间,默认的大小取决于物理机上的内存大小。

在JVM启动时可以指定堆内存的大小,也是JVM内存调优的关键。

设置JVM内存的参数有四个,其中第四个是设置栈内存的参数:
-Xmx Java Heap最大值,默认值为物理内存的1/4,最佳设值应该视物理内存大小及计算机内其他内存开销而定;
-Xms Java Heap初始值,Server端JVM最好将-Xms和-Xmx设为相同值,开发测试机JVM可以保留默认值;
-Xmn Java Heap Young区大小,不熟悉最好保留默认值;
-Xss 每个线程的Stack大小,不熟悉最好保留默认值。

堆也可以理解成一块逻辑上连续而物理上不连续的内存空间,几乎所有的实例都在这里分配内存,在方法结束后,堆中的对象不会马上删除,仅仅在垃圾收集的时候被删除,堆是GC(垃圾收集器)执行垃圾回收的重点区域,也是内存溢出的重点灾区。

简单来说,相对于栈存放的是变量的引用,堆则是存储实例变量的本身。

堆的组成部分在不同的Java版本里略有区别:

  • Java7:新生代、老年代、永久代
  • Java8:新生代、老年代、元空间

新生代 Young Generation

包含1个新生区(Eden)和2个幸存区(Survivor),默认比例为: 8:1:1,新生代的GC为Minor GC。

两个幸存区也分别叫from和to,或者s0和s1,或者servivor1和survivor2。

新生区:当初始加载对象时会进入新生区。
幸存区:两个幸存区始终会有一个区为空,为空的区即是to区。其不会主动进行垃圾回收,只有新生区回收时才会附带进行GC。GC开始时对象只会存在于新生区和幸存区的From区中,幸存区的对象会从From区中复制到To区,这些存活的对象在到达一定年龄(默认15)后会被移到老年代。

新生代中的基本流程是:新生区内存不足时,会进行YGC(Young GC)将没有指针的对象回收,剩余的还有指针引向的对象放入一个幸存区中。下一次GC的时候,新生区的对象以及还存活的幸存区的对象都会放入另一个空的幸存区中,同时超龄的对象会被移入老年代。

新创建的对象都会被分配到新生区(一些大的对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时(可以通过-XX:MaxTenuringThreshold来设置),就会被移动到老年代中。

因为新生代中的对象基本都是朝生夕死的(80%以上),所以在新生代的垃圾回收算法使用的是复制算法(replication algorithm),复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面,复制算法不会产生内存碎片。

老年代 Old Generation

老年代主要存放应用中生命周期长的内存对象,它的GC为Major GC。

老年代比较稳定,不会频繁的进行MajorGC。而在Maior GC之前才会先进行一次Minor GC,新生的对象进入老年代时空间不够才会触发Major GC。当无法找到足够大的连续空间分配给新创建的较大对象也会提前触发一次Major GC进行垃圾回收腾出空间。

在老年代中,Major GC采用了标记—清除算法(mark-sweep algorithm),首先扫描一次所有老年代里的对象,标记出存活的对象,然后回收没有标记的对象。MajorGC的耗时比较长。因为要扫描再回收。MajorGC会产生内存碎片,当老年代也没有内存分配给新来的对象的时候,就会抛出OOM(Out of Memory)异常。

永久代 Permanent Generation

永久代指的是永久保存区域。

永久代是Hotspot虚拟机特有的概念,是方法区的一种实现,别的JVM都没有这个东西。在Java 8中,永久代被彻底移除,取而代之的是另一块与堆不相连的本地内存——元空间。永久代主要存放Class和Meta(元数据)的信息。Class在被加载的时候被放入永久区域,它和存放的实例的区域不同。

元空间 Meta Space

元空间和永久代类似,最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。

默认情况下,元空间的大小仅受本地内存的限制。类的元数据放入native memory,字符串池和类的静态变量放入java堆中。这样可以加载多少类的元数据就不再由MaxPermSize控制,而由系统的实际可用空间来控制。

元空间替代永久代的好处有:

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

注:堆栈这个概念存在于数据结构中,也存在于JVM虚拟机中,但是这两个概念不是相同的。

  1. JVM中的栈是先进先出(FIFO),先入栈的先执行;
  2. 数据结构中的栈是先进后出(FILO),类似于洗碗后堆起来的碟子,先洗完的叠最下面,下次用的时候最后才用到它。
    在数据结构中,堆是完全二叉树,堆中个元素是有序的。在这个二叉树中所有的双亲节点和孩子节点存在着大小关系,如所有的双亲节点都大于孩子节点则为大头堆,如果所有的双亲节点都小于其孩子节点说明这是一个小头堆,建堆的过程就是一个排序的过程,堆的查询效率也很高。而栈是一种特殊的线性表,具有先进后出,只允许在一端(栈顶)插入、删除的特点。

方法区(非堆)Method Area

方法区,也称非堆(Non-Heap),又是一个被线程共享的内存区域。其中主要存储加载的类字节码、class/method/field等元数据对象、static-final常量、static变量、编译器编译后的代码等数据。另外,方法区包含了一个特殊的区域“运行时常量池”。

方法区其实是一个虚拟机的逻辑规范,其具体的实现原先是永久代(Java7及以前),即堆中,转移到元空间(Java8及以后),即本地内存中。

程序计数器 Program Counter Register

JVM中的程序计数器,也叫PC寄存器,是对物理PC寄存器的一种抽象模拟,用于记录正在执行的虚拟机字节码指令的地址。

每个线程启动的时候,都会创建一个PC寄存器, 每一个线程都有它自己的PC寄存器,是线程私有的。JVM的PC寄存器保存下一条将要执行的指令地址,也可以理解成是一个指针。如果执行的是一个Native方法,那这个计数器是空的(Underfined)。在虚拟机栈有一帧帧的栈帧组成,而栈帧包含局部变量表,操作栈等子项,那么线程在运行的时候,代码在运行时,是通过PC寄存器不断执行下一条指令。真正指令运算等操作时通过控制操作栈的操作数入栈和出栈,将操作数在局部变量表和操作栈之间转移。

在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

程序计数器占用的内存空间很少,也是唯一一个在JVM规范中没有规定任何OutOfMemoryError(内存不足错误)的区域。

引申一下,寄存器,物理层面上的PC寄存器,是一个中转站,连接者CPU和内存,他是用来存放当前指令的。
电脑执行程序的过程就是CPU不断执行指令的过程。一条指令分为:操作和地址,在执行一条指令的时候,先把指令从内存取到数据寄存器,然后再取到指令寄存器,然后再交给指令译码器来转换指令,再向操作控制器发出对应的信号,然后计算下条指令的地址,并送入程序计数器。

JVM内存总结

  1. 虚拟机栈,线程独享,存放:局部变量(基本类型)、局部变量的引用地址(引用类型)、
  2. 本地方法栈,线程独享,存放:本地方法的局部变量表、本地方法的操作数栈等
  3. 堆,线程共享,存放:对象的实例(其成员变量不论是基本类型还是引用类型都一并与对象同在)、常量池
  4. 方法区:线程共享,存放:被虚拟机加载的类、静态变量、常量等
  5. 程序计数器:线程独享,存放:下一条要执行的指令的地址,执行本地方法指令时计数器为空
定义位置 作用范围 默认值 内存位置 生命周期
局部变量 方法内部 方法内 基本类型:栈 引用类型:栈-引用 堆-对象 从方法进栈到方法出栈
成员变量 类内,方法体外 整个类 取决于数据类型 从对象创建到对象回收

类加载器

类加载器负责加载字节码文件,即java编译后的 .class 文件。虚拟机把描述类的数据从class字节码文件加载到内存,并对数据进行检验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

类从被加载到虚拟机内存到被卸载,整个完整的生命周期包括:类加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证,准备,解析三个部分统称为连接。

  • 加载:把字节码文件通过类加载器载入内存中
  • 连接:

    • 验证:验证文件格式、类型、元数据、字节码、符号引用等
    • 准备:为类变量分配内存,赋予初值,如int=0,reference为null
    • 解析:将常量池中的符号引用替换成直接引用的过程,例如:method()方法的地址值是@xxxxxxx,hello是符号引用,@xxxxxxx是直接应用
  • 初始化:对类变量(静态变量)初始化,即static修饰的变量或语句,优先顺序是父先与子,自上而下的顺序执行。

类加载器的双亲委托机制是指多个类加载器之间存在父子关系的时候,某个class类具体由哪个加载器进行加载的问题。其具体的过程表现为:当一个类加载的过程中,它首先不会去加载,而是委托给自己的父类去加载,父类又委托给自己的父类。

因此所有的类加载都会委托给顶层的父类,即Bootstrap Classloader进行加载,然后父类自己无法完成这个加载请求,子加载器才会尝试自己去加载。使用双亲委派模型,Java类随着它的加载器一起具备了一种带有优先级的层次关系,通过这种层次模型,可以避免类的重复加载,也可以避免核心类被不同的类加载器加载到内存中造成冲突和混乱,从而保证了Java核心库的安全。

启动/根类加载器(Bootstrap)←拓展类加载器(Extension)←应用程序类加载器(Application)←自定义加载器(Custom)

执行引擎

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

执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。所有的 Java 虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解释执行的等效过程,输出的是执行结果。

字节码指令集

字节码指令对于Java虚拟机JVM,就像汇编语言对于对于计算机,属于基本执行指令。

源代码(.java文件)经过编译器编译之后便会生成一个字节码文件(.class文件),字节码是一种二进制的类文件,它的内容是JVM的指令,而不像C、C++经由编译器直接生成机器码。我们不用担心生成的字节码文件的兼容性,因为所有的JVM全部遵守Java虚拟机规范,也就是说所有的JVM环境都是一样的,这样一来字节码文件可以在各种JVM上运行。

在Java虚拟机的指令集中,大多数的指令都包含了其所操作的数据类型信息。简单来说,字节码指令集可以理解成一本字典,里面存放着将Java的class文件里面的字节码翻译成机器能理解并执行的机器码。

JVM生命周期

JVM伴随Java程序的开始而开始(根类加载器开始加载),程序的结束而停止(程序执行完毕、程序的异常或错误导致、操作系统错误导致)。一个Java程序会开启一个JVM进程,一台计算机上可以运行多个程序,也就可以运行多个JVM进程。

JVM是一份本地化的程序,本质上是可执行的文件,是静态的概念。程序运行起来成为进程,是动态的概念。 java程序是跑在JVM上的,严格来讲,是跑在JVM实例上的,一个JVM实例其实就是JVM跑起来的进程,二者合起来称之为一个JAVA进程。 各个JVM实例之间是相互隔离的。

JVM将线程分为两种:守护线程和普通线程。守护线程是JVM自己使用的线程,比如垃圾回收(GC)就是一个守护线程。普通线程一般是Java程序的线程,只要JVM中有普通线程在执行,那么JVM就不会停止。

你可能感兴趣的