Java 学习之 JVM

本文详细介绍了Java虚拟机(JVM)的概念、内存布局(包括程序计数器、虚拟机栈、本地方法栈、堆、方法区等)以及类的加载机制,包括加载、验证、准备、解析、初始化等步骤。同时,文章讨论了不同类型的垃圾回收算法,如标记-清除、复制、标记整理和分代收集,并探讨了类加载的触发时机和类加载器的工作原理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

概念:Java虚拟机(Java  Virtual  Machine 简称 JVM)是运行所有Java程序的抽象计算机,是Java语言的运行环境,也是java的重要特性之一。

介绍:Java (JVM)一种用于计算机设备的规范,可用不同的方式(软件或硬件)加以实现。编译虚拟机的指令集与编译微处理器的指令集非常类似。JVM虚拟机包括一套字节码指令集、一组存储器、一个栈、一个垃圾回收堆、一个存储方法域。

           JVM也可以理解为一台可运行Java程序的假象计算机。只要根据JVM的规则描述将解释器移植到特定的计算机上,就能够保证经过编译的任何Java代码能够在该程序上运行。通俗点说就是Java 程序能够运行在任意一台运行安装了JVM的平台,也体现了Java语言的一处编译,随处运行的语言特性。JVM可以以一次一条指令的方式来解释字节码(把它映射到实际的处理器指令),或者字节码也可以由实际处理器中称作just-in-time的编译器进行进一步编译。

特点:上边介绍了Java的“一处编译,随处运行”,可见Java语言的与平台的无关性。而这一特点的关键就在“一处编译”这一环节,也就是JVM(Java虚拟机)。对于要在特定平台上,JVM 只需要把字节码解释成具体平台上的机器指令去执行就可以了。

应用:Java虚拟机(JVM)是Java语言底层实现的基础,这有助于理解Java语言的一些性质,也有助于使用Java语言。对于要在特定平台上实现Java虚拟机的软件人员,Java语言的编译器作者以及要用硬件芯片实现Java虚拟机的人来说,则必须深刻理解Java虚拟机的规范。如果想扩展Java语言,或是把其他语言编译成Java的字节码就需要深入了解Java的JVM.

一、Java虚拟机的内存布局;

       JVM虚拟机在执行Java程序过程中会在内存空间中分配一片区域,用于程序的运行。

                                                                             图  1-1

       如上图;虚拟机又会把这块管理的内存划分为若干个不同的数据区域,即虚拟机栈、本地方法区、程序计数器、堆、方法区。左侧两个为线程私有区域,右侧三个为线程共享区域。

  • 线程共享

在运行时数据区中,方法区和堆是属于线程公有的,也就是这两块区域是“循环利用”的,所以要对其进行垃圾回收。其是在虚拟机启动时创建。

  • 线程私有

虚拟机栈、本地方法栈、程序计数器是属于线程私有的,其与线程“同生死”,属于“一次性”的,所以不用对其进行垃圾回收。

       1、程序计数器:当线程所执行的字节码的行号指示器(实际是指令的偏移地址)。是JVM 分给程序运行的一块较小的内存空间,字节码解释器工作时就是通过计数器的值来选取下一条索要执行的字节码指令。

        既然是描述字节码的行号指示器,我们通过dome来模拟一段字节码,便于理解

public void JustDoIt(){
    1.  String name = "";
    2.  String age = "";
    3.  String phoneON = "";
    4.  xxxxx......
    
}

       上述dome中的 1  2  3  4  代表的就是字节码行号(指令的偏移地址),字节码解释器就是根据这个值来执行字节指令的。了解了其概念之后我们来理解它的特点及工作原理。

        我们知道,java 语言是多线程的,当CPU 执行权从A线程切换至B 线程的时候,程序调用sleep()函数将线程A挂起,线程B 开始执行,线程B在执行完或者线程A 重新拿到CPU执行权以后,需要切回至A线程继续完成A线程所要处理的程序的时候,这时候程序计数器的值则记录着A线程上次执行的行号(偏移地址),字节码解释器此时根据这个值,来继续A线程没有完成的工作。

         综上,我们知道程序计算器的数量取决于线程数,各个线程对应自己独立的程序计数器,这样才不至于线程执行中造成的混乱,也是为什么说程序计算器是线程私有的了。

         明白了其原理后,最后就是什么时候创建程序计数器的问题了,了解线程机制就知道,线程在执行过程中随时都会失去CPU 的执行权,所以程序计数器在一个线程开始执行的时候就被创建了。

         程序计数器记录的是字节指令的偏移地址,所以,一个线程在执行过程当中,其程序计数器记录的值只会发生改变,而不需要重新分配内存来记录新的指令偏移地址,所以不存在内存溢出的情况,所以这也就是为什么程序计数器是唯一一个没有规定OutOfMemoryError 异常 的区域

        2、虚拟机栈:Java方法的执行模型,用于存储局部变量表,操作数栈、动态链接、方法出口等。

              每个Java 方法在执行的时候会创建一个栈帧(“stack frame”), 此时的栈帧结构包含局部变量表、操作数栈、动态链接、方法出口四部分。通常所说的“堆内存”、“栈内存”,指的就是虚拟机栈内存,再准确点说就是指的是虚拟机栈中的局部变量表内存,因为这里存放了一个方法的所有局部变量。具体流程:线程执行->调用方法->创建栈帧->并压入虚拟机栈->方法执行完毕->栈帧出栈并被销毁。网络配图如下;

       如上图,每一个方法执行开始到结束,都是一个栈帧从入栈到出栈的过程。

       存放的是编译期可知的各种基本数据类型,对象引用,returnAddress类型。所以其所需的内存空间在编译期间就能完成  分配,在运行期间不会改变其大小。  在分配基本数据类型所占的空间时,除了64位的long和double类型的数据会占用2个局部变量空间,其余的数据类型只 占用1个。

        如果线程请求的栈深度,大于虚拟机所允许的深度,将抛出StackoverflowError(栈溢出异常):线程运行时,JVM会为虚拟机栈分配一定的内存,因此,虚拟机栈能容纳的栈帧数量是有限的,若栈帧不断的进栈但不出栈,最终会导致虚拟机栈的内存耗尽,比如死循环,此时会报StackOverflowError(栈溢出异常)错误。

        如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryErrorr(内存溢出异常);与StackOverflowError异常相类似,当虚拟机栈将能够使用的最大内存耗尽时,便会抛出OutOfMemoryErrorr。

       3、本地方法栈:本地方法栈和虚拟机栈的作用是相同的,只不过虚拟机栈执行的是java方法,本地方法栈执行的是Native方法。java方法就是开发人员写的java代码,Native方法就是一个java调用非java代码的接口。

       4、堆:用于存放对象的内存区域,因此,堆是垃圾回收器(GC)管理的主要目标。其具有如下特点;

  • 堆在逻辑上划分为“新生代”和“老年代”。由于JAVA中的对象大部分是朝生夕灭,还有一小部分能够长期的驻留在内存中,为了对这两种对象进行最有效的回收,将堆划分为新生代和老年代,并且执行不同的回收策略。不同的垃圾收集器对这2个逻辑区域的回收机制不尽相同,在后续的章节中我们将详细的讲解。
  • 堆占用的内存并不要求物理连续,只需要逻辑连续即可。
  • 堆一般实现成可扩展内存大小,使用“-Xms”与“-Xmx”控制堆的最小与最大内存,扩展动作交由虚拟机执行。但由于该行为比较消耗性能,因此一般将堆的最大最小内存设为相等。
  • 堆是所有线程共享的内存区域,因此每个线程都可以拿到堆上的同一个对象。
  • 堆的生命周期是随着虚拟机的启动而创建。

      5、方法区:也称非堆(Non  Heap),线程共享的内存区域,其主要存储的是加载的类字节码、class/method/field等元数据对象,static-final常量、static变量、jit编译器编译后的代码等数据。另外需要特别说明的有一个特殊区域“运行时常量池”,在JDK 1.7以前,运行时常量池属于方法区,以后则被划入到堆中。

  • 加载的类字节码:就是将java代码编译的字节码文件,
  • class/method/field等元数据对象:字节码在加载之后,JVM会根据其中的内容生成class/method/field等对象,他们用于描述一个类,通常在反射中用的比较多。
  • static-final常量、static变量:对于这两种类型的类成员,JVM会在方法区为他们创建一份数据,因此同一个类的static修饰的类成员只有一份。
  • 堆占用的内存并不要求物理连续,只需要逻辑连续即可。

方法区无法满足内存分配需求时,抛出OutOfMemoryError异常。

      6、直接内存:当class文件被加载后,虚拟机所控制(图1-1 JVM 运行时数据内存区域)之外的内存空间,NIO可以通过native方法库直接 分配内存,扩展时也会出现OutOfMemoryError 异常。

二、JVM体系总体分为四大块;

        2.1、类的加载机制

                 全盘负责,当一个类加载器负责加载某个Class时,该Class依赖的和引用的其他Class也将由该类加载器负责                载入,除非显示使用另外一个加载器来载入

                 父类委托,先让父类加载器加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。

                 缓存机制,缓存机制将会保证所有加载过的Class都会被缓存,当程序需要某个Class时,类加载器先从缓存区              寻找该Class,只有缓存区不存在,系统才能读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区,这就是修        改了Class之后必须重启JVM,程序的修改才会生效。

        2.2、JVM内存结构

                 如图1-1

        2.3、GC算法,内存回收

                2.3.1、内存分配与回收策略

                            先看下图;

                        

        图中所示为堆中内存分配示意图,当程序创建一个对象首先会在eden当中分配一块区域,如果内存不够,就    会将年龄较大的对象转移到survivor 区域,当该区域存放不下则会将对象转入老年代区域。对于一些静态变量不需要使用对象,直接被调用的则会被放入永生代(即非堆内存),一般来说长期存活的对象最终会被放入年老代 ,或者创建大对象,比如数据之类的需要申请联系空间,且空间较大的,则直接放入到年老代。

        在回收过程中,有个描述对象年龄的参数,如果在一次垃圾回收过程中,有使用过该对象则系统对该对象年龄参数+1,否则-1,当计数至0时,则进行垃圾回收。如果年龄达到一定峰值,则对象进入老年代。总的来说内存分配机制只要体现在创建的对象是否还在使用,不使用则回收,使用则对年龄进行更新,最终将放入年老代。

                 2.3.2、垃圾回收算法

                             2.3.2.1、标记-清除算法

           该算法先标记后清除,将所有需要回收的算法进行标记,然后清除,这种算法的缺点是;效率低,标记清除后会出现大量零散的内存碎片,又是一种内存消耗,造成内存浪费以及时间的消耗。

          首先解释效率低的问题,当JVM执行垃圾回收时,GC线程被触发启动并将程序(正在运行的JAVA程序)暂停,随后根搜索算法递归遍历全堆对象,将依旧存活的对象标记,接着将没有标记的对象清除,最后让程序恢复运行。

          再来解释内存消耗和时间消耗,被清除的死亡对象都是零散的分布在内存中,清除之后内存布局乱七八糟,为了解决这个问题,JVM又不得不维持一个空闲的内存列表,浪费内存空间,用此算法执行垃圾回收需要两步,一、递归对象并标记。 二、递归并清除,由此造成的时间浪费。

          最后,从用户体验角度来说,又是一个缺点,上边提到当执行垃圾回收的时候,GC线程启动并将程序暂停,为什么呢?试想一下,当GC线程执行完标记对象A后,此时我们new 了一个对象B ,B对象又引用对象A ,此时的B对象是没有被标记的,这时候清除的时候就会将B 清除(明明new 了一个对象结果是个null 这怎么玩?),所以暂停程序-执行回收-恢复程序,由此带来的用户体验是很差的。

                             2.3.2.2、复制算法

           复制算法将可用的内存分为两份,每次使用其中一份,当这块回收之后把未回收的复制到另一    块内存中,然后把使用的清除。这种算法运行简单,解决了标记清除算法的碎片问题,但是这种算    法的代价过高,需要将可用的内存缩小一半,对象存活率较高时,需要持续的复制工作,效率低。

           通俗点解释就是复制算法会将内存划分为两块,在任意时候,所有动态的对象都只能分配在其中一个的一个区间(称为活动区间),而另一个区间则是空闲的(成为空闲区间)。当有效的内存耗尽时,JVM将暂停程序运行,开启复制算法GC线程,接下来GC线程会把活动区间内的存活对象全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址到新的内存地址。此时,活动区间与空闲区间已经交换,原来活动区间上残留的就是垃圾对象,系统将这一部分对象回收。

         优点:对内存利用率较高。

         最后,不难看出这种算法也有其致命的缺点;一、一半内存的浪费。二、复制算法对于存活率很高的对象,每一次回收前都要对其复制,然后更新其指向的内存地址,将耗费很大一部分时间。

                             2.3.2.3、标记整理 算法

           标记整理算法是针对复制算法在对象存活率较高时而需要进行持续复制造成的效率低的问题的    改进,该算法是在标记清除算法的基础上不直接清理,而是使存货对象往一端游走,然后清除一端    边界以外的内存,这样既可以避免不连续空间出现,还可以避免对象成活率较高的持续复制。这种算法可以百分百避免对象存活的极端状况,因此老年代不能直接使用该算法。

            具体点来说:标记整理算法回收也分为两部分;一、当JVM执行垃圾回收时,GC线程被触发启动并将程序(正在运行的JAVA程序)暂停,随后根搜索算法递归遍历全堆对象,将依旧存活的对象标记。二、移动所有存活的对象,且按照内存地址次序依次排列,然后将内存地址末端的内存全部回收,这就是整理阶段。

             好处:弥补了标记\整理算法后内存分散的特点,也消除了复制算法当中内存减半的高额代价。

             缺点:实质是对前两种算法结合优化,所以效率是最低的,因为首先要对全堆对象进行遍历标记,然后对标记过的对象的引用地址进行整理。再其次就是这个算法适用于低存活率对象的局限性。

                              2.3.2.4、分代收集算法 

            综上所述,前三种回收算法都有其局限性,也就是说他们只适用在合适的堆内存分区中,所以第四种算法应运而生,分代回收算法就是虚拟机目前使用的回收算法,它解决了标记整理不适用年老代的问题,将内存分为各个年代, 在不同年代使用合适的算法,新生代存活率低,可以是引用复制算法。而年老代  存活率较高,没有额外的空间对其进行分配担保,所以只能使用标记清除或者标记整理算法。      

        2.4、GC分析  命令调优

                GC调优的概念:在软件工程领域,如果希望提高一个系统的吞吐量,可以着手从延迟,吞吐量两方面来考虑,要么投入更多的硬件成本,要么提高系统性能,缩短延迟时间。希望对给定的硬件环境充分利用,在给定  的硬件环境基础上实现最优性能。

               2.4.1、延迟(Latency)

               2.4.2、吞吐量(Throughput)

               2.4.3、系统容量(Capacity)

               根据上述三个性能方面的衡量维度

三、类的加载机制;

       3.1、什么是类的加载

                将Java类加载到JVM的东西,我们称之为类加载器。JVM使用类的方式如下;Java源程序(.java文件),在经过Java          编译器编译之后就被转换成Java字节代码(.class文件)。类加载器负责读取Java字节代码,并转换成java.lang.Class类的          一个实例。每个这样的实例表示一个Java类。通过此实例的 newlnstance()方法就可以创建出该类的一个对象。

       3.2、Java类加载的时机

                3.2.1、类加载的生命周期

                 类加载的生命周期是指类从被加载到内存开始,直至卸载出内存为止的这一过程。整个周期分为;加载、          验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析称为连接。如下示意图;

                     

                              下面简单介绍类加载器所执行的生命周期的过程;

                                     (1)、装载:查找和导入Class文件;

                                     (2)、链接:把类的二进制数据合并到JRE中;      

                                                  1、校验:检查载入Class文件数据的正确性。

                                                  2、准备:给类的静态变量分配存储空间。

                                                  3、解析:将符号引用转成直接引用。

                                      (3)、初始化:对类的静态变量,静态代码块执行初始化操作。

                3.2.2、类加载的时机

                            类加载的时机JVM使用规范中并没有强制规定,但是在一下五个场景必须立即执行初始化,被称作主动引用

                            (1) 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触 发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候,读取或设      置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一      个类的静态方法的时候。

                            (2)、使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初    始化。

                            (3)、当初始化一个类的时候,发现其父类还没有进行过初始化,则要先触发其父类的初始化。

                            (4)、当JVM启动时,用户需要指定一个要执行的主类(包含main方法的那个类),虚拟机会先初始化这个主    类。

                            (5)、当使用JDK 1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果                  REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且该方法句柄所对应的类没有初始      化过,则先触发初始化。

                3.2.3、Java类加载的过程

                           3.2.3.1、加载

                                         (1)、通过一个类的权限定名来获取定义此类的二进制字节流;

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

                                         (3)、在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。

                           3.2.3.2、验证

                                                 验证阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危  害JVM自身的安全。会对四个方面进行验证;

                                         (1)、文件格式验证

                                         (2)、元数据验证

                                         (3)、字节码验证

                                         (4)、符号引用验证

                           3.2.3.3、准备

                                                准备阶段是正是为类变量分配并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。    注:这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量会      在对象实例化时随着对象一起分配在Java堆中,这里所说的初始值通常情况下是数据类型的零值。

                           3.2.3.4、解析

                                                 解析阶段是JVM将常量池中的符号引用替换成直接引用的过程。直接引用是直接指向目标的指      针相对偏移量或是一个能间接定位到目标的句柄。直接引用与JVM 实现的内存有关,同一个符号引      用在不同JVM的实例上翻译出来的直接引用不尽相同。

                           3.2.3.5、初始化

                                                 初始化阶段是类加载过程的最后一步,到了该阶段才真正开始执行类定义的Java程序代码,根据程序员通过代码定制的主观计划去初始化类变量和其他资源,是执行类构造器初始化方法的过程。

四、类加载器

              类加载器负责加载所有的类,为所有被载入到内存中的类生成一个java.lang.Class中的一个实例对象。如果一个类被加载到JVM中,同一个类就不会被再次载入了,正如一个对象只有一个唯一标识一样,一个被载入到JVM的类也会有 一个唯一标识。在Java中(即未被编译的.java文件)一个类用全限定类名(类名+包名)作为标识,但是在JVM 中(即经过JVM 编译过的.class文件),一个类用其权限定名和类加载器作为唯一标识。

               JVM预定义有三种类加载器,当一个JVM启动的时候,Java开始使用以下三种类加载器:

               (1)、根类加载器(bootstrap  class loader):用来加载Java的核心类,此加载类为C++实现,所以并不继承自java.lang.Classloader,换句话说与其他加载器不同,根类加载器并不是java.lang.Classloader的子类。

               (2)、扩展类加载器(extensions class loader):负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类,由java语言实现,福类加载器为null.

               (3)、系统类加载器(system class loader ):被称为系统类加载器(也称为应用类加载器),它负责JVM启动时加载来自Java命名的 -classpath选项,java.class.path系统属性。系统可以通过ClassLoader的静态方法getSystemClassLoader()来获取类加载器,由Java语言实现,父类记载器为ExtClassLoader。

       类加载器加载Class大致需要8个步骤;

              1、检测此Class是否被载入过,即在缓存区是否有次Class,如果有直接进入第8步,否在第2步。

              2、如果没有父类加载器,则要么Parent是根类加载器,要么本身就是根类加载器,则跳入第4步,如果父类记载器存在则第3步。

              3、请求使用父类加载器载入目标类,如果载入成功则跳入第8步,否则接着执行第5步。

              4、请求使用根类加载器载入目标类,如果成功则跳入第8步,否则第7步。

              5、当前类的加载器尝试寻找CLass文件,如果找到则执行第 6步,否则执行第7步。

              6、从文件中载入Class,成功则跳至第8步。

              7、抛出ClassNotFountException异常。

              8、返回对应的java.lang.Class对象。

五、Java引用的四种状态;

              1、强引用:应用最广泛,平时 new 一个对象放在堆内存,然后用一个引用指向它,这就是强引用。

              如果一个对象具有强引用,那垃圾回收机制不会回收它。当内存不足,Java虚拟机宁愿出OutOfMemoryError异常,是程序终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。

              2、软引用:如果一个对象具有软引用,则内存空间足够时,垃圾回收器就不会回收它,但是内存如果不足,则随时都有可能被回收。只要未被回收,则程序可以继续使用该对象。软引用用来实现敏感的高速缓存。

              3、弱引用:与软引用类似,区别在于拥有弱引用的生命周期更为短暂,每次执行GC的时候,一旦发现有弱引用的对      象,不论当前内存空间足够与否,都会回收它的内存。但是,垃圾回收器是一个优先级和低的线程。 

              4、虚引用:与其他几种引用不尽相同,虚引用不会决定对象的生命周期。如果一个对象具有虚引用,那么它和没有任    何引用一样,在任何时候都可能被垃圾回收器回收。

六、结尾,下面通过一张图对本文的技术点进行一个直观的展示(图片来源于网络)

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值