JVM内存模型及类加载运行机制

前言

运行一个java应用程序,必须要先安装JDK或者JRE包,因为java应用在编译后会变成字节码,通过字节码运行在JVM中,而JVM是JRE的核心组成部分,JVM不仅承担了java字节码的分析和执行,同时也内置了自动内存分配管理机制,这个机制可以大大降低手动分配回收机制可能带来的内存泄露和内存溢出的风险,使java开发人员不需要关注每个对象的内存分配和回收,从而更关注业务的本身。

JVM内存模型主要分为堆、方法区、程序计数器、虚拟机栈、本地方法栈五个部分,其中堆和方法区是所有线程共享的,虚拟机栈、本地方法栈、程序计数器是线程私有的。
在这里插入图片描述

堆是JVM内存中最大的一块内存空间,该内存被所有的线程共享,几乎所有的对象和数组都被分配到堆内存中,堆被划分为新生代和老年代,新生代被进一步分为Eden(伊甸区)和Survivor(幸存区)区,Survivor区由From Survivor和To Survivor组成。不过需要注意,这些区域的划分因不同的垃圾收集器而不同,大部分垃圾收集器都是基于分代收集理论设计的,就会采用这种分代模型,而一些新的的垃圾收集器不采用分代设计,比如G1收集器就是把堆内存拆分为多个大小相等的Region(区)。
在这里插入图片描述

方法区

在JDK8之前,HotSopt虚拟机的方法区又被称为永久代,由于永久代的设计容易导致内存溢出的问题,所以JDK8之后就没有永久代了,由元空间(MetaSpace)取代它,元空间并没有处于堆内存中,而是直接占用本地内存,因此元空间的最大大小受本地内存的限制。

方法区与堆空间类似,是所有线程共享的,方法区主要是用来存放已被虚拟机加载的类型信息、常量、静态变量等数据。方法区是一个逻辑分区,包含元空间、运行时常量池、字符串常量池,元空间物理上使用的本地内存,运行时常量池和字符串常量池是在堆中开辟的一块特殊内存区域。这样做的好处之一就是可以避免运行时动态生成的常量的复制迁移,可以直接使用堆中的引用。要注意的是,字符串常量池在JVM中只有一个,而运行时常量池是和类型数据绑定的,每个Class一个。
在这里插入图片描述

类型信息(类或接口)

  • 这个类型的全限定名
  • 这个类型的直接超类的全限定名(只有java.lang.Object没有超类)
  • 这个类型的访问修饰符(public、abstract、final)
  • 这个类型是接口类型还是类类型
  • 任何直接超借口的全限定名的有序列表

运行时常量池

  • Class文件被装载进虚拟机后,Class常量池表中的字面量和符号引用都会存放到运行时常量池,平时我们说的常量池一般指运行时常量池
  • 运行时常量池相比Class常量池具备动态性,运行时可以将新的常量放入池中,比如调用String.intern()方法使字符串驻留。

字段信息

  • 字段名
  • 字段的类型(包括void)
  • 字段的修饰符(public、private、protected、static、final、volatile、transient)

方法信息

  • 方法名
  • 方法的返回类型
  • 方法参数的数量和类型
  • 方法的修饰符(public、private、protected、static、final、synchronized、native、absract)
  • 方法的字节码
  • 操作数栈和该方法的栈帧中的局部变量的大小
  • 异常表

指向类加载器的引用

jvm使用类加载器来加载一个类,这个类加载器是和这个类型绑定的,因此会在类型信息中存储这个类加载器的引用。

指向Class类的引用

  • 每一个被加载的类型,jvm都会在堆中创建一个java.lang.Class的实例,类型信息中会存储Class实例的引用
  • 在代码中,可以使用Class实例访问方法区保存的信息,如类加载器、类名、接口等。

虚拟机栈

每当启动一个新的线程,虚拟机都会在虚拟机栈里为它分配一个线程栈,线程栈与线程同生共死,线程栈以栈帧为单位保存线程的运行状态,虚拟机只会对线程栈执行两种操作:以栈帧为单位的压栈或出栈。每个方法在执行的同时都会创建一个栈帧,每个方法从调用开始到结束,就对应着一个栈帧在线程栈中压栈和出栈的过程。方法可以通过两种方式结束,一种通过return正常返回,一种通过抛出异常而终止。方法返回后,虚拟机都会弹出当前帧然后释放掉。

当虚拟机调用一个java方法时,它从对应类的类型信息中得到此方法的局部变量区和操作数栈的大小,并据此分配栈帧内存,然后压入java栈中。栈帧由三部分组成:局部变量区、操作数栈、帧数据区。
在这里插入图片描述

局部变量区

  • 局部变量区是一个数据结构,主要存放对应方法的参数和局部变量
  • 如果是实例方法,局部变量表第一个参数是一个reference引用类型,存放的是当前对象本身this

操作数栈

  • 操作数栈也是一个数据结构,但并不是通过索引来访问的,而是栈的压栈和出栈操作的
  • 操作数栈是虚拟机的工作区,大多数指令都要从这里弹出数据、执行运算、然后把结果压回操作数栈

帧数据区(主要保存常量池入口、异常表、正常方法返回的信息)

  • 常量池入口引用:某些指令要从常量池取数据,获取类、字段信息等
  • 异常表引用:当方法抛出异常时,虚拟机根据异常表来决定如何处理。如果在异常表找到了匹配的catch字句,就会把控制权转交给catch字句的代码,没有则立即异常终止,然后恢复发起调用的方法的栈帧,然后在发起调用的方法上下文中重新抛出同样的异常
  • 方法返回信息:方法正常返回时,虚拟机通过这些信息恢复发起调用的方法的栈帧,设置PC寄存器指向发起调用的方法。方法如果有返回值,还会把返回结果压入到发起调用的方法的操作数栈。

本地方法栈

本地方法栈与虚拟机所发挥的作用类似,当线程调用java方法时,会创建一个栈帧并压入虚拟机栈,而调用本地方法时,虚拟机会保持栈不变,不会压入新的栈帧,虚拟机只是简单的动态链接并直接调用指定的本地方法,使用的是某种本地方法栈。比如某个虚拟机实现的本地方法接口是使用C连接模型,那么它的本地方法栈就是C栈。

本地方法可以通过本地方法接口访问虚拟机的运行时数据区,它可以做任何它想做的事情,本地方法不受虚拟机控制。

程序需计数器

每一个运行的线程都会有它的程序计数器(PC寄存器),与线程的生命周期一样,执行某个方法时,PC寄存器的内容总是下一条将被执行的地址,这个地址可以是一个本地指针,也可以是在方法字节码中相对于该方法起始指令的偏移量。如果该线程正在执行一个本地方法,那么此时PC寄存器的值是undefined。

程序计数器是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。多线程环境下,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储。

类加载机制

写好的源代码,需要编译后加载到虚拟机才能运行。java 源文件编译成 class 文件后,jvm 通过类加载器把 class 文件加载到虚拟机,然后经过类连接(类连接又包括验证、准备、解析三个阶段),最后经过初始化,字节码就可以被解释执行了。对于一些热点代码,虚拟机还存在一道即时编译,会把字节码编译成本地平台相关的机器码,以提高热点代码的执行效率。在这里插入图片描述
装载、验证、准备、初始化这几个阶段的顺序是确定的,类型的加载过程必须按照这种顺序开始,而解析阶段可以在初始化阶段之后再开始,一般是在第一次使用到这个对象时才会开始解析。这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段,比如发现引用了另一个类,那么就会先触发另一个类的加载过程。

类加载

类初始化的时机

类和接口被加载的时机因不同的虚拟机可能不同,但类初始化的触发时机有且仅有六种情况:

  • 当创建某个类的实例,如 new、反射、克隆、反序列化
  • 当调用某个类的静态方法时
  • 当使用某个接口或类的静态字段,或者赋值时(final 修饰的常量除外,它在编译期把结果放入常量池中了)
  • 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化
  • 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化(接口除外)
  • 当虚拟机启动时,会先初始化要执行的主类(包含main()方法的那个类)

这六种情况称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。

触发接口初始化的情况:

  • 类初始化时并不会触发其实现的接口的初始化,接口初始化时也不会要求父接口初始化
  • 在接口所声明的非常量字段被使用时,该接口才会被初始化
  • 如果接口定义了 default 方法,那子类重写了这个方法,就会先触发接口的初始化

主动初始化

对 final 常量的引用不会触发类的初始化,调用静态方法时触发了类的初始化,同时,一定会先触发父类的初始化,而且类只会被初始化一次,并且初始化的顺序是按代码的顺序从上到下初始化,例如一个类里有多个静态代码块,则按照静态代码的顺序进行初始化。

被动初始化(如下被动引用不会触发类的初始化)

  • 通过子类引用父类的静态字段,不会导致子类初始化。对于静态字段,只有直接定义这个字段的类才会被初始化
  • 通过数组定义来引用类,不会触发此类的初始化。但是会触发一个 “[com.lyj.jvm.test01.User” 类型的初始化,即一维数组类型
  • 引用类的常量不会触发类的初始化。常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

加载

在加载阶段,Java虚拟机必须完成以下三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

这个二进制流可以从 Class 文件中获取,从JAR包、WAR包中获取,从网络中获取,实时生成、还可以从加密文件中获取,在加载时再解密(防止Class文件被反编译)。这个加载是由类加载器加载进虚拟机的,非数组类型可以使用内置的引导类加载器来加载,也可以使用开发人员自定义的类加载器来加载,我们可以自己控制字节流的获取方式。而数组类型本身不通过类加载器加载,它是由虚拟机直接在内存中构造出来的。

加载阶段会把 Class 常量池中的各项常量存放到运行时常量池中。加载阶段的最终产品就是 Class 类的实例对象,它成为程序与方法区内部数据结构之间的入口,可以通过这个 Class 实例来获得类的信息、方法、字段、类加载器等等。
在这里插入图片描述

在装载过程中,虚拟机还会确认装载类的所有超类是否都被装载了,根据 super class 项解析符号引用,这就会导致超类的装载、连接和初始化。

验证

这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段会完成下面四个阶段的检验:

  • 文件格式验证:保证输入的字节流能正确地解析并存储于方法区之内,通过这个阶段的验证之后,这段字节流会进入Java虚拟机内存的方法区中进行存储,后面的验证就是基于方法区的存储结构而进行了。
  • 元数据验证:对类的元数据信息进行语义校验,如这个类是否有父类(除 java.lang.Object 外,所有的类都有父类)、是否继承了 final 的类、实现了 final 的方法等。
  • 字节码验证:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。
  • 符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。

准备

准备阶段是为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,初始值是指这个数据类型的零值,而赋值的过程是放在 方法中,在初始化阶段执行的。注意实例变量是在创建实例对象时才初始化值的。

解析

解析过程就是根据符号引用查找到实体,再把符号引用替换成一个直接引用的过程。因为所有的符号引用都保存在常量池中,所以这个过程常被称作常量池解析。

静态解析与动态链接

所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在运行期间用到时转化为直接引用,这部分称为动态连接。

静态解析的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。这类方法包含 静态方法、私有方法、实例构造器、父类方法以及被 final 修饰的方法,这5种方法调用会在类加载的时候就把符号引用解析为该方法的直接引用(有可能是在初始化的时候去解析的)。

动态连接这个特性给Java带来了更强大的动态扩展能力,比如使用运行时对象类型,因为要到运行期间才能确定具体使用的类型。这也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

符号引用解析

对于符号引用类型如 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等,会查找到对应的类型数据、方法地址、字段地址的直接引用,然后将符号引用替换为直接引用。

对于 CONSTANT_String _info 类型指向的字面量,虚拟机会检查字符串常量池中是否已经有相同字符串的引用,有则替换为这个字符串的引用,否则在堆中创建一个新的字符串对象,并将对象的引用放到字符串常量池中,然后替换常量池中的符号引用。

对于数值类型的常量,如 CONSTANT_Long_info、CONSTANT_Integer_info,并不需要解析,虚拟机会直接使用那些常量值。
在这里插入图片描述

初始化

直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,初始化阶段就是执行类构造器 方法的过程。

方法

  • 方法是由编译器自动收集类中的所有类变量的赋值语句和静态代码块合并产生的,代码执行的顺序就是源文件中的顺序。
  • Java虚拟机会保证在子类的 方法执行前,父类的 方法会先执行完毕,即先初始化直接超类。
  • 方法对于类或接口来说不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 方法。
  • 执行接口的 方法不需要先执行父接口的 方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 方法。
  • Java虚拟机会保证一个类的 方法在多线程环境中被正确地加锁同步, 一定是线程安全的。

即时编译

初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行。在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,那就是即时编译。

最初,虚拟机中的字节码是由解释器( Interpreter )完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中,这样可以减少解释器的中间损耗,获得更高的执行效率。如果没有即时编译,每次运行相同的代码都会使用解释器编译。

类加载器

类加载器子系统

在Java虚拟机中,负责查找并装载类型的那部分被称为类加载器子系统。类加载器子系统会负责整个类加载的过程:装载、验证、准备、解析、初始化。

java虚拟机有两种类加载器,启动类加载器和用户自定义加载器

  • 启动类加载器:是Java虚拟机实现的一部分,启动类加载器主要用来加载受信任的Java API 的 Class 文件。
  • 用户自定义类加载器:是Java程序的一部分,用户自定义的类加载器都是 java.lang.ClassLoader 的子类实例,开发人员可以自己控制字节流的加载方式。

类唯一性

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间,由不同的类加载器加载的类将被放在虚拟机内部的不同命名空间中。比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。这就是有时候我们测试代码时发现明明是同一个Class,却报强转失败之类的错误。

双亲委派模型

Java 1.8 之前采用三层类加载器、双亲委派的类加载架构。三层类加载器包括启动类加载器、扩展类加载器、应用程序类加载器。

三层类加载器

  • 启动类加载器(Bootstrap ClassLoader):负责将 $JAVA_HOME/lib 或者 -Xbootclasspath 参数指定路径下面的文件(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载) 加载到虚拟机内存中。它用来加载 Java 的核心库,是用原生代码实现的,并不继承自 java.lang.ClassLoader,启动类加载器无法直接被 java 代码引用。
  • 扩展类加载器(Extension ClassLoader):负责加载 $JAVA_HOME/lib/ext 目录中的文件,或者 java.ext.dirs 系统变量所指定的路径的类库,它用来加载 Java 的扩展库。
  • 应用程序类加载器(Application ClassLoader):一般是系统的默认加载器,也称为系统类加载器,它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般 Java 应用的类都是由它来完成加载的,可以通过 ClassLoader.getSystemClassLoader() 来获取它。

双亲委派模型

除了启动类加载器之外,所有的类加载器都有一个父类加载器。应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器。一般来说,开发人员自定义的类加载器的父类加载器一般是应用程序类加载器。

双亲委派模型:类加载器在尝试去查找某个类的字节代码并定义它时,会先代理给其父类加载器,由父类加载器先去尝试加载这个类,如果父类加载器没有,继续寻找父类加载器,依次类推,如果到启动类加载器都没找到才从自身查找。这个类加载过程就是双亲委派模型。

首先要明白,Java 虚拟机判定两个 Java 类是否相同,不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两个类来源于同一个Class文件,并且被同一个类加载器加载,这两个类才相等。不同类加载器加载的类之间是不兼容的。

双亲委派模型就是为了保证 Java 核心库的类型安全的。所有 Java 应用都至少需要引用 java.lang.Object 类,也就是说在运行的时候,java.lang.Object 这个类需要被加载到 Java 虚拟机中。如果这个加载过程由 Java 应用自己的类加载器来完成或者自己定义了一个 java.lang.Object 类的话,很可能就存在多个版本的 java.lang.Object 类,而这些类之间是不兼容的。通过双亲委派模型,对于 Java 核心库的类加载工作由引导类加载器来统一完成,保证了 Java 应用所使用的都是同一个版本的 Java 核心库的类,是互相兼容的。有了双亲委派模型,就算自己定义了一个 java.lang.Object 类,也不会被加载。
在这里插入图片描述

ClassLoader

类加载器之间的父子关系一般不是以继承的关系来实现的,通常是使用组合、委托关系来复用父加载器的代码。ClassLoader 中有一个 parent 属性来表示父类加载器,如果 parent 为 null,就会调用本地方法直接使用启动类加载器来加载类。类加载器在成功加载某个类之后,会把得到的 java.lang.Class 类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。
在这里插入图片描述

线程上下文类加载器

线程上下文类加载器可通过 java.lang.Thread 中的方法 getContextClassLoader() 获得,可以通过 setContextClassLoader(ClassLoader cl) 来设置线程的上下文类加载器。如果没有通过 setContextClassLoader(ClassLoader cl) 方法进行设置的话,线程将继承其父线程的上下文类加载器。Java 应用运行的初始线程的上下文类加载器是应用程序类加载器。在线程中运行的代码可以通过此类加载器来加载类和资源。线程上线文类加载器使得父类加载器可以去请求子类加载器完成类加载的行为,这在一定程度上是违背了双亲委派模型的原则。

对象及其生命周期

实例化对象

实例化一个类有四种途径

  • 明确地使用 new 操作符
  • 调用 Class 或者 java.lang.reflcct.Constructor 对象的 newInstance() 方法
  • 调用任何现有对象的 clone() 方法
  • 通过 java.io.ObjectInputStream 类的 getObject() 方法反序列化

实例化对象的过程

  • 1、当虚拟机要实例化一个对象时,首先从常量池中找到这个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就会触发相应的类加载过程。
  • 2、在类加载检查通过后,虚拟机将为新生对象分配内存,为对象分配空间就是把一块确定大小的内存块从Java堆中划分出来。
  • 3、内存分配完成之后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
  • 4、接下来,虚拟机还要对对象进行必要的设置,例如这个对象的类型信息、元数据地址、对象的哈希码、对象的GC分代年龄等信息,这些信息存放在对象的对象头之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
  • 5、最后开始执行对象的构造函数,即Class文件中的 方法,按照开发人员的意图对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

对象的内存布局

在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

1)对象头:

对象头主要由两部分组成:Mark Word 和类型指针,如果是数组对象,还会包含一个数组长度。

  • Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。synchronized 锁升级就依赖锁标志、偏向线程等锁信息,垃圾回收新生代对象转移到老年代则依赖于GC分代年龄。
  • 类型指针:对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
  • 数组长度:有了数组长度,虚拟机就可以通过普通Java对象的元数据信息确定Java对象的大小,如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

这三部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特。64 位虚拟机中,为了节约内存可以使用选项 +UseCompressedOops 开启指针压缩,某些数据会由 64位压缩至32位。

2)实例数据:

实例数据部分是对象真正存储的有效信息,即对象的各个字段数据,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

3)对齐填充:

对齐填充仅仅起着占位符的作用,由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被设计成正好是8字节的倍数,因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

4)计算对象占用内存大小:

从上面的内容可以看出,一个对象对内存的占用主要分两部分:对象头和实例数据。在64位机器上,对象头中的 Mark Word 和类型指针各占 64 比特,就是16字节。实例数据部分,可以根据类型来判断,如 int 占 4 个字节,long 占 8 个字节,字符串中文占3个字节、数字或字母占1个字节来计算,就大概能计算出一个对象占用的内存大小。当然,如果是数组、Map、List 之类的对象,就会占用更多的内存。

对象访问定位

创建对象后,这个引用变量会压入栈中,即一个 reference,它是一个指向对象的引用,这个引用定位的方式主要有两种:使用句柄访问对象和直接指针访问对象。

1)通过句柄访问对象:

使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。

使用句柄来访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
在这里插入图片描述

2)通过直接指针访问对象:

如果使用直接指针访问的话,Java堆中对象的内存布局就必须放置访问类型数据的相关信息(Mark Word 中记录了类型指针),reference 中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。

使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,HotSpot 虚拟机主要就是使用这种方式进行对象访问。
在这里插入图片描述

垃圾收集

当对象不再被程序所引用时,它所使用的堆空间就需要被回收,以便被后续的新对象所使用。JVM 的内存分配管理机制会自动帮我们回收无用的对象,它知道如何确定对象不再被引用,什么时候去回收这些垃圾对象,使用什么回收策略来回收更高效,以及如何管理内存,这部分就是JVM的垃圾收集相关的内容了。

原文链接

猜你喜欢

转载自blog.csdn.net/qq_40093255/article/details/115078725
今日推荐