初识JVM

文章目录

    • 一、JVM的体系结构
    • 二、类加载机制
      • 1、类的加载
      • 2、类的连接
      • 3、类的初始化
    • 三、各区域介绍
      • 1、native方法(本地方法接口、本地方法库)
      • 2、程序计数器
      • 3、方法区
      • 4、JVM栈
      • 5、堆
    • 四、对象的生命周期
    • 五、GC详解
    • 六、总结
    • 部分参考资料

一、JVM的体系结构

在了解JVM的体系结构之前,我们需要知道它在整个系统中的位置。如下图所示,JVM是运行在操作系统之上的,与硬件没有直接的交互(但可以通过本地方法调用间接实现对硬件的控制)。
初识JVM_第1张图片
接下来考虑JVM的体系结构,将上图中的JVM层剖析开来,可以分解为以下几个结构:类加载器运行时数据区(方法区(元空间)、JVM栈、程序计数器、堆区、本地方法栈)、执行引擎本地方法接口以及本地方法库(其中堆和方法区被所有线程共享)。
初识JVM_第2张图片
初识JVM_第3张图片
最后介绍一下类的生命周期,如上图所示,可分为三个阶段:

  • 类的创建(类加载机制):对应于加载、连接和初始化阶段
  • 类的使用:涉及到了对象的生成,使用(如何创建对象,对象内存如何分配…)
  • 类的消亡:当下面三种情况都成立时,类会被卸载
    1)该类的所有实例都已经被回收;
    2)加载该类的ClassLoader已经被回收
    3)该类对象的Class对象没有在任何地方被引用,即无法在任何地方通过反射访问该类的方法

JVM一项重要的任务就是管理类和对象的生命周期,而这正是通过JVM各区域间的协调控制所实现的,下面将对每个模块逐一进行介绍。

二、类加载机制

从体系结构图中可以发现,类的加载机制分为三部分:加载、连接和初始化,对应于类生命周期的第一阶段。

1、类的加载

类加载指的是将类的class文件读入内存,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。这里可以把类也理解为一种模板对象,用来生成实例化对象。
在理解类的加载前,先弄清两个问题:

  • 什么时候启动类的加载

1)遇到new、getstatic、putstatic 等指令时。
2)对类进行反射调用的时候。
3)初始化某个类的子类的时候。
4)虚拟机启动时会先加载设置的程序主类。
5)使用JDK 1.7 的动态语言支持的时候
直观来说,当需要某个类的时候,就需要进行类的加载

  • 加载的来源是什么?

本地文件、jar包、网络加载、java源动态编译执行等

那么有了时机和资源,类加载机制的第一步———类的加载就开始了。
首先,JVM会去方法区中查找是否有对应的.class文件,如果查找成功,表明类已经被加载到JVM中,可直接使用现有的class内容;如果没有,需要启动类的加载,该步骤需要类加载器来完成。
类加载器一共有四种:

  • 根类加载器(BootStrap)(BootClassLoader): sun.boot.class.path (加载系统的包,包含jdk核
    心库里的类)
  • 扩展类加载器(Extension)(ExtClassLoader): java.ext.dirs(加载扩展jar包中的类)
  • 系统(应用)类加载器(System)(AppClassLoader) :java.class.path(加载你编写的类,编译后的类)
  • 用户类加载器:用户自定义的类加载器,需要继承ClassLoader类并进行方法重写。

类加载器会遵循几个机制来保证类成功加载:

  • 全盘负责

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

  • 父类委托

先让父类加载器试图加载该Class,父类加载器无法加载时才尝试从自己的类路径中加载该类。(这里的父子关系是基于类加载器的层次结构:根->扩展->系统->用户)

  • 缓存机制

缓存机制会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,系统会先在缓存区搜索该Class对象,如果搜索不到才会读取该类对应的二进制文件,将其转换成Class对象并存入缓存区。所以修改了Class后需要重新启动JVM,程序做的修改才能生效。

加载步骤
(1)根据加载来源和相应的类加载器获取类对应的二进制字节流;
(2)将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构;

首先.class文件被类加载器加载到jvm,存放在方法区中,class文件中除了存放类的信息,字段、方法等描述信息之外,还有个常量池:包含了在编译期生成的字面量(量本身-也就是值)和符号引用(在解析过程变成直接引用)这部分的内容在类加载进入方法区的常量池(class常量池->运行时常量池)中存放。
源码层面深化
类加载器加载最终调用的是 jvm.cpp (native方法,位于本地方法库)中的 jvm_define_class_common()方法,该方法利用 ClassFileStream 将要加载的class文件转成文件流,然后调用SystemDictionary::resolve_from_stream(),生成 Class 在 JVM 中的代表:Klass。Klass是JVM 用来定义一个Java Class 的数据结构。不过Klass只是一个基类,Java Class 真正的数据结构定义在 InstanceKlass中。InstanceKlass 中记录了一个 Java 类的所有属性,包括注解、方法、字段、内部类、常量池等信息。这些信息本来被记录在Class文件中,所以说,InstanceKlass就是一个Java Class 文件被加载到内存后的形式。InstanceKlass 分配在 ClassLoader的 Metaspace(元空间) 的方法区中。详见参考资料[2]

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

这里要注意不是方法区,方法区只是创建了class的类型信息,Classloader加载一个类并把类型信息保存到方法区后,会创建一个Class对象,存放在堆区,它为程序提供了访问类型信息的方法。详见参考资料[5]

  • 不严谨的说,类加载可以看成是在JVM中生成了一个“类模板”。
  • 这里涉及了常量池的概念,常量池一共包含三种:class常量池、字符串常量池和运行时常量池,该部分内容详见参考资料[4]。

初识JVM_第4张图片

2、类的连接

初识JVM_第5张图片

类的连接分为验证、准备和解析三部分。

这里需要注意类的加载和连接实际上是交叉进行的,并不存在绝对意义上的前后关系。

1)验证

验证的主要作用就是确保被加载的类的正确性,需要去检查加载进来的字节码文件,分为四部分:
(1)文件格式的验证:验证.class文件字节流是否符合class文件的格式的规范,并且能够被当前版本的虚拟机处理。这里面主要对魔数、主版本号、常量池等等的校验(魔数、主版本号都是.class文件里面包含的数据信息、在这里可以不用理解)。
(2)元数据验证:主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求,比如说验证这个类是不是有父类,类中的字段方法是不是和父类冲突等等。
(3)字节码验证:这是整个验证过程最复杂的阶段,主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证阶段对数据类型做出验证后,这个阶段主要对类的方法做出分析,保证类的方法在运行时不会做出威海虚拟机安全的事。
(4)符号引用验证:它是验证的最后一个阶段,发生在虚拟机将符号引用转化为直接引用的时候。主要是对类自身以外的信息进行校验。目的是确保解析动作能够完成。
对整个类加载机制而言,验证阶段是一个很重要但是非必需的阶段,如果我们的代码能够确保没有问题,那么我们就没有必要去验证,毕竟验证需要花费一定的的时间。当然我们可以使用-Xverfity:none来关闭大部分的验证

2)准备

该步骤较为简单,主要是对静态区域(class文件加载到方法区时会分为静态区域和非静态区域)的内容进行默认初始化。

3)解析

该步骤会将虚拟机常量池内的符号引用替换为直接引用。(比如String s =“hello”, 转化为 s的地址指向“aaa”的地址)

3、类的初始化

在初始化阶段,主要为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:

  • 声明类变量是指定初始值
  • 使用静态代码块为类变量指定初始值
    初识JVM_第6张图片

因为子类存在对父类的依赖,所以类的加载顺序是先加载父类后加载子类,初始化也一样。不过,父类初始化时,子类静态变量的值也有,是默认值。
更准确的说,这个阶段实际就是执行类构造器< clinit >()方法的过程,该方法执行规则如下:
初识JVM_第7张图片
可以看出,与类中生成类对象的构造器类似,实际上就是生成类模板的初始化构造器,主要完成对静态内容的初始化操作。

最终,方法区会存储当前类类信息,包括类的静态变量、类初始化代码(定义静态变量时的赋值语句 和 静态初始化代码块)、实例变量定义、实例初始化代码(定义实例变量时的赋值语句实例代码块和构造方法)和实例方法,还有父类的类信息引用。

三、各区域介绍

1、native方法(本地方法接口、本地方法库)

由于历史原因,java诞生之初就在内存中开辟了一块特殊的区域用于处理标记为native的代码,使用native方法的原因主要是为了java可以与外部环境或系统底层进行交互,但目前该方法使用的越来越少。
JNI:Java Native Interface (Java本地方法接口)

用native关键字修饰的方法称之为本地方法接口,该方法的方法体实现并不由java实现,而是由本地的C/C++语言实现。本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。JVM会在 Native Method Stack(本地方法栈) 中登记native方法,在 ( Execution Engine ) 执行引擎执行的时候加载Native Libraies(本地方法库)。

2、程序计数器

初识JVM_第8张图片
每个线程都有一个程序计数器,是线程私有的。
程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。是一个非常小的内存空间,几乎可以忽略不计。总的来说,程序计数器有以下几个特点:

  1. 程序计数器指定下一条需要执行的指令
  2. 每一个线程独有一个程序计数器
  3. 执行java代码时,寄存器保存当前指令地址
  4. 执行native方法时候,寄存器为空
  5. 不会造成OutOfMemoryError情况

3、方法区

初识JVM_第9张图片
Method Area(方法区)是 Java虚拟机规范中定义的运行时数据区域之一,它与堆(heap) 一样在线程之间共享,主要是用来存储类的描述信息(元数据)。Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。JDK7 之前(被称为永久代Perm)用于存储已被虚拟机加载的类信息、常量、字符串常量、类静态变量、即时编译器编译后的代码等数据。每当一个类初次被加载的时候,类的元数据都会被放到永久代中。永久代大小有限制,如果加载的类太多,很可能导致永久代内存溢出,即java.lang.OutOfMemoryError:PermGen.
JDK8 彻底将永久代移除出 HotSpot JVM,将其原有的数据迁移至 Java Heap 或 Native Heap(Metaspace),取代它的是另一个内存区域被称为元空间(Metaspace)。
元空间(Metaspace):元空间是方法区在 HotSpot JVM 中的实现,主要用于存储类信息、运行时常量池、方法数据、方法代码、符号引用等。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。

:如果Metaspace的空间占用达到了设定的最大值,那么就会触发GC来收集死亡对象和类的加载器。可以通过-XX:MetaspaceSize-XX:MaxmetaspaceSize配置元空间内存大小。

4、JVM栈

初识JVM_第10张图片
JVM栈主管Java程序的运行,在线程创建时创建,是线程私有的。栈的生命周期跟随线程的生命周期,线程结束栈内存也就释放。因此对于栈来说不存在垃圾回收问题,只要线程一旦结束,该栈就Over。
栈的组成:栈里面存放的是栈帧,栈帧是一种用于帮助虚拟机执行方法调用与方法执行的数据结构。他是独立于线程的,一个线程有自己的一个栈帧。封装了方法的局部变量表、动态链接信息、方法的返回地址以及操作数栈等信息。一个方法从调用开始到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
一个线程中的方法调用链可能会很长,很多方法都同时处理执行状态。对于执行引擎来讲,活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引用所运行的所有字节码指令都只针对当前栈帧进行操作。
栈帧:一个栈帧一般由下面几个部分组成

  • 局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法表的Code属性的max_locals数据项中确定了该方法需要分配的最大局部变量表的容量。
在Java文件编译期间就会分配方法局部变量表的最大容量,局部变量表以变量槽为单位,每个变量槽可以存储32位及32位以下的变量,具体大小根据变量实际占用内存而定,java的基本类型中除了long和double外其他类型都是32位以下,所以每个变量占用一个变量槽即可,而对于long和double类的变量,会占用两个变量槽,除了基本类型当然还有引用类型,引用类型变量长度JVM并没有明确定义。JVM通过索引的方式来访问变量表中的变量,索引从0开始。变量槽是可以重复使用的,当变量槽所存储的变量已经不在其作用域后,该变量槽就可以被其他变量占用。
局部变量不像前面介绍的类变量那样存在“准备阶段”。类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的值。因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值是不能使用的。

  • 操作数栈

操作数栈也常被称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也是编译的时候被写入到方法表的Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。栈容量的单位为“字宽”,对于32位虚拟机来说,一个”字宽“占4个字节,对于64位虚拟机来说,一个”字宽“占8个字节。
当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。例如,在做算术运算的时候就是通过操作数栈来进行的,又或者调用其它方法的时候是通过操作数栈来行参数传递的。
另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递。

  • 动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

  • 方法返回地址

当一个方法被执行后,有两种方式退出这个方法:
1)第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法方式称为正常完成出口(Normal Method Invocation Completion)。
2)另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的调用都产生任何返回值的。
无论采用何种方式退出,在方法退出之前,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。 方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令等。

栈中可能出现的异常

  • 如果线程请求分配的栈容量超过java虚拟机栈所允许的最大容量,java虚拟机就会抛出StackOverfolwError
  • 如果java虚拟机栈动态扩展,在扩展时没有申请到足够的内存或者是创建新线程时没有足够的内存再创建java虚拟机栈了,那么java虚拟机就会抛出outOfMemoryError

例如方法间的循环调用会导致栈内存溢出。

5、堆

初识JVM_第11张图片
一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的,类加载器读取了类文件后,需要把类,方法,常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行。
堆内存在逻辑上分为年轻代(Young Generation)、老年代(Old Generation)和元空间。年轻代又分为Eden和Survivor区。Survivor区由FromSpace和ToSpace组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1。元空间是JDK8提出的,在本地内存中,取代了之前在堆中的永久代。
初识JVM_第12张图片
从上面可以看出堆内存被划分成了好几个部分,实际上这是为了进行分代管理,协调垃圾回收机制。GC垃圾回收主要是在年轻代和老年代,又分为轻GC(MinorGC) 和 重GC(Full GC),如果内存不够,或者存在死循环,就会导致java.lang.OutOfMemoryError: Java heap space
分代管理:(GC部分详细讨论)
年轻代是类诞生,成长,消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。
年轻代又分为两部分:伊甸区(Eden Space)和幸存者区(Survivor Space),所有的类都是在伊甸区被new出来的,幸存区有两个:from区 和 to区,当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC)。将伊甸园中的剩余对象移动到幸存0区,若from区也满了,再对该区进行垃圾回收,然后移动到to区,那如果to区也满了,(这里幸存from区和to区是一个互相交替的过程)就会移动到老年代,若老年代也满了,那么这个时候将产生MajorGC(Full GC),进行老年代的内存清理,若老年代执行了Full GC后发现依然无法进行对象的保存,就会产生OOM异常 OutOfMemoryError
如果出现 java.lang.OutOfMemoryError:java heap space异常,说明Java虚拟机的堆内存不够,原因如下:
1、Java虚拟机的堆内存设置不够,可以通过参数 -Xms(初始值大小),-Xmx(最大大小)来调整。
2、代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)或者死循环

堆内存调优:通过调整堆内存相关的参数设置,以提升程序性能。这些参数对Java虚拟机中非常重要的,对程序性能有着重要的影响。下面是部分常用的参数:
1) -Xms 初始堆内存大小,当Java进程启动时,虚拟机会分配一块初始堆空间,可以使用-Xms指定这块空间大小。在实际工作中,我们一般把-Xms与-Xmx的值设置为相等,这样的好处是在程序运行时减少GC的次数,从而提高程序性能。
2) -Xmx 当程序在运行时,堆初始空间消耗殆尽,虚拟机会对堆空间进行扩展,其扩展上限是最大堆空间,使用-Xmx参数来指定。(方便大家好记住,这里mx可以暂时理解为max最大)
3)-Xmn 此参数是用来指定新生代的大小(堆内存是分为新生代, 老年代,永久带-在jdk1.8后移除此属性),新生代分为Eden、from、to空间。设置一个较大新生代会降低老年代的大小,这个参数设置对系统性能已经GC行为有极大的影响。 新生代一般设置为堆内存的1/3-1/4左右。
4)-XX:SurvivorRatio 用来设置新生代中Eden空间和from/to空间的比例关系,表达式如下:-XX:SurvivorRatio=eden/from=eden/to。
5)-XX:NewRatio 可以设定老年代与新生代的比例。
6)-XX:+PrintGCDetails :输出详细的GC处理日志

四、对象的生命周期

大致了解了JVM的体系结构后,接下来考虑对象在内存中如何创建、使用和回收,这也对应于类生命周期的第二个阶段(类的使用)。
对象创建方式

  1. 使用new关键字
  2. Class对象的newInstance()方法
  3. 构造函数对象的newInstance()方法
  4. 对象反序列化
  5. Object对象的clone()方法
  6. 使用Unsafe类创建对象

对象内存结构
初识JVM_第13张图片
1.对象头

图中可以看出对象头分为MarkWord与Class对象指针,其中MarkWord标识了对象运行时的各种属性与状态值,哈希码(HashCode).GC分代 年状 态标志、线程持有的锁、偏向线程ID、偏向时间戳等。 而Class对象指针则指向一个类在被类加载器读入内存后生成的Class对象的内存地址,这样就可以通过对象判断它是哪个类的实例。

2.实例数据

实例数据是保存对象真正有效的数据,也就是对象的各种字段信息,其中也包括从父类中继承的字段,都保存在这里,当然方法不在这里,在类中。而且它的内存具体分配结构是受jvm分配策略与字段在源码中的顺序来决定的,默认是相同宽度的字段分配在一起,在这个前提下父类字段在子类字段前面,而且子类中较窄的字段也可能被分配到父类中的间隙中。

3.对齐填充 (可选项)

因为hotspot虚拟机的内存管理系统要求内存的起始地址必须是8的整数倍,所以这段填充就是为了保证地址是8的整数倍。

对象加载步骤:(这里以new创建为例,不同的创建方式会稍微有些不同)
1)当虚拟机遇到一条new指令时候,首先去检查这个指令的参数是否能 在常量池中能否定位到一个类的符号引用 (即类的带路径全名),并且检查这个符号引用代表的类是否已被加载、解析和初始化过,即验证是否是第一次使用该类。如果没有(不是第一次使用),那必须先执行相应的类加载过程。(类的第一阶段)
2)在类加载检查通过后,接下来虚拟机将为新生的对象分配内存 。对象所需的内存的大小在类加载完成后便可以完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来(新创建的对象一般在分配在堆的新生代区),目前常用的有两种方式,根据使用的垃圾收集器的不同使用不同的分配机制:

  • 指针碰撞(Bump the Pointer):假设Java堆的内存是绝对规整的,所有用过的内存都放一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
  • 空闲列表(Free List):如果Java堆中的内存并不是规整的,已使用的内存和空间的内存是相互交错的,虚拟机必须维护一个空闲列表,记录上哪些内存块是可用的,在分配时候从列表中找到一块足够大的空间划分给对象使用。

3)内存分配完后,虚拟机需要将分配到的内存空间中的数据类型都初始化为零值(不包括对象头);
4)虚拟机要对对象头进行必要的设置 ,例如这个对象是哪个类的实例(即所属类)、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些信息都存放在对象的对象头中。至此,从虚拟机视角来看,一个新的对象已经产生了。但是在Java程序视角来看,执行new操作后会接着执行如下步骤:
5)调用对象的init()方法,根据传入的属性值给对象属性赋值。
6)在线程栈中新建对象引用 ,并指向堆中刚刚新建的对象实例

对象的使用
为了能够使用对象,需要去访问对象,一般有两种方式:

  • 句柄访问
    初识JVM_第14张图片
  • 直接指针
    初识JVM_第15张图片

对象的回收:这部分涉及到垃圾回收机制,主要是对未使用的对象进行清理以释放占用内存。

最后,贴一张图来进一步理解一下这部分内容。
初识JVM_第16张图片

本节参考资料为[7][8][9]

五、GC详解

垃圾回收(Garbage Collection,GC),是指释放垃圾占用的空间,防止内存泄露。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。
垃圾回收的区域:堆和方法区(元空间),主要集中在堆区,方法区次数较少,但是对应于类生命周期的第三阶段(消亡),一旦方法区中的类信息被垃圾回收掉,那么下次使用需要重新加载。
初识JVM_第17张图片
垃圾回收的判断:引用计数法(由于无法解决循环依赖问题而未使用)和可达性分析算法。目前虚拟机基本都是采用可达性算法,从GC Roots 作为起点开始搜索,那么整个连通图中的对象边都是活对象,对于GC Roots 无法到达的对象变成了垃圾回收对象,随时可被GC回收。
可作为GC Roots对象

  • 栈帧中本地变量表中引用的对象
  • 方法区类静态属性引用的对象
  • 方法区中常量引用的对象
  • 方法栈JNI引用的对象

可达对象是否死亡判断

  • 第一次标记

在对象可达性算法不可达时,进行第一次标记,并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize方法或者该方法被虚拟机调用过,虚拟机将这两种情况视为“没有必要去执行”。如果该对象被判定为有必要执行finalize()方法,那么这个对象会被放置到一个叫做F-Queue的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalize线程去执行它。这里所谓的执行就是去触发该方法,但是并不会承诺等待它执行结束,这样做的原因是,如果对象在finalize()方法中执行缓慢,或者发生死循环,将会导致整个队列中的对象处于等待之中。

  • 第二次标记

finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中拯救自己——只要重新与引用链上的一个对象重新建立关联即可,譬如将自己(this关键字)赋值给某个类变量或者成员变量,那么在第二次标记的时候就会被移除“即将回收”的集合;如果对象这时候还没有逃脱,那么就会被真的回收了

垃圾回收算法
1)标记清除算法

标记清除算法(Mark-Sweep)是最基础的一种垃圾回收算法,它分为2部分,先把内存区域中的这些对象进行标记,哪些属于可回收标记出来,然后把这些垃圾拎出来清理掉。就像上图一样,清理掉的垃圾就变成未使用的内存区域,等待被再次使用。
缺点:存在内存碎片、效率低。

2)复制算法

复制算法(Copying)是在标记清除算法基础上演化而来,解决标记清除算法的内存碎片问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。保证了内存的连续可用,内存分配时也就不用考虑内存碎片等复杂情况。
缺点

  • 浪费了一半的内存
  • 对象存活率高时复制次数频繁,降低效率

3)标记整理算法

标记-整理算法标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。标记整理算法解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。标记整理算法对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。

上述三种方式往往都存在某方面的问题,比较如下:

  • 回收效率:复制算法 > 标记清除算法 > 标记整理算法 (时间复杂度)
  • 内存整齐度:复制算法 = 标记整理算法 > 标记清除算法
  • 内存利用率:标记整理算法 = 标记清除算法 > 复制算法

既然都不能做到最好,那么就取长补短,让不同算法在最合适的场景进行运用。

4)分代收集算法

分代收集算法分代收集算法严格来说并不是一种思想或理论,而是融合上述3种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳,根据对象存活周期的不同将内存划分为几块。

  • 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
  • 在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清理算法或者标记-整理算法来进行回收

初识JVM_第18张图片

根据上述算法,我们再来看看对象在内存中的分配策略:
首先,在大多数情况下,一个新生对象一般放在堆内存的Eden区(大对象由于占用内存资源较多,可能会直接放进老年代),当Eden区内存不够时会触发一次Minor GC,此时存活的对象会放入Survivor区的from区;
其次,Survivor区相当于是Eden区和Old区的一个缓冲,Survivor分为2个区,一个是From区,一个是To区。每次执行Minor GC,会将Eden区中存活的对象放到Survivor的From区,而在From区中,仍存活的对象会根据他们的年龄值来决定去向。达到回收阈值(15次)的对象将会被放入养老区,其他则会采用复制算法复制到To区,复制完成后,From区和To区的逻辑概念会交换。(From Survivor和To Survivor实际上是一种逻辑关系,可以简单理解为From区就是下次复制的源区域,To区是目的区域,这样做的目的是保证有连续的空间存放对方,避免碎片化的发生)

Survivor区的作用:如果没有Survivor区,Eden区每进行一次Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次Minor GC没有消灭,但大多数对象实际上都是临时对象,或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。所以,Survivor的存在意义就是减少被送到老年代的对象,进而减少Full GC的发生。

最后,一旦老年代区域内存也不够了,那么JVM会进行一次Full GC,此时老年代会采用标记清除或标记整理算法进行一次清理,若老年代执行了Full GC后发现依然无法进行对象的保存,就会产生OOM异常 OutOfMemoryError。此时需要增加堆内存或排查代码错误。

六、总结

经过上面的讨论,我们大致了解JVM的内存结构(注意与内存模型JMM区分)以及类和对象在JVM中的生存周期,但JVM的知识点众多,这里也只是提供一个基本的学习框架和路线,更多的细节可以去看《深入Java虚拟机》这本书。

本文参考了较多网络资料,列举如下,侵删。

部分参考资料

[1] java创建对象的过程详解
[2] 你知道 Java 类是如何被加载的吗?
[3] Java类加载机制,你理解了吗?
[4] Class常量池、字符串常量池和运行时常量池
[5] JVM的Class对象详解
[6] 深入理解Java虚拟机笔记—运行时栈帧结构
[7] java对象在内存中的结构(HotSpot虚拟机)
[8] 类的加载和内存分配过程
[9] Java中new一个对象的步骤
[10] JVM内存回收机制
[11] 可达性算法中不可达的对象是否一定会死亡
[12] java中的垃圾回收机制,你理解了嘛

你可能感兴趣的:(JVM系列)