JVM作为运行Java的基础平台,是Java最为重要的核心,所以想要对Java有较深的理解以及运行原理,对象的内存分配等,需先了解JVM的结构、原理、内存模型、如果加载并运行class文件,本篇简单介绍JVM的内存模型
JVM内存模型
-
区域划分
JVM内存模型主要针对于运行时数据区来进行展开,运行时数据区分为:堆、栈、方法区、程序计数器,其中栈分为虚拟机栈、本地方法栈
-
堆:最大得内存区域,保存基本上所有的实例对象,所以堆也是GC主要发生的区域,线程公有的区域
-
栈:方法会栈运行,作为一个个的栈帧,其中栈帧包含:局部变量表,操作数栈,动态链接,方法出口。栈中的数据都是方法私有的
-
方法区:保存静态变量、常量、类信息等,线程共享的内存区域,也可以将方法区叫做"永久代"
-
程序计数器:保存当前执行的代码的字节码指令行号以及将要执行的字节码指令的行号,唯一一块不会发生OOM的内存区域,线程私有的内存区域
-
对象布局
-
对象的布局
对象在内存中的布局为:对象头、实例数据、对齐填充
- 对象头:主要分为2个部分,第一部分:类型指针,第二部分:MarkWork区域
- 类型指针:指向类的指针,表示该对象是通过该类创建,如果对象是数组类型,则还有一块区域用来保存数组的长度
- MarkWord:主要包含了:hashcode、GC分代年龄、锁标志位、线程持有的锁等信息
- 实例数据:对象中包含的各种类型的字段
- 对齐填充:不一定存在对齐填充,JVM的内存管理系统要求对象的内存为8字节的倍数,如果没有达到8的倍数,则会通过对齐填充来进行弥补,起到占位符的作用
- 对象头:主要分为2个部分,第一部分:类型指针,第二部分:MarkWork区域
-
对象的创建
- 类加载过程
类加载的过程主要分为3个大的阶段:加载-连接-初始化,其中连接阶段又分为:验证、准备、解析
-
加载
类加载器将字节码文件(
.class
)加载到内存中获取到二进制字节流,在内存中生成一个代表这个类的java.lang.Class
对象,作为方法区中这个类的各种数据的访问入口 -
验证
验证主要是对加载的Class文件进行验证,保证是格式正确,并且可以被JVM正确加载的字节码文件。
-
文件格式验证
验证是否已魔数开头,并且文件格式是否正确,当前的Class文件的版本是否在当前JVM的处理范围内
-
元数据验证
这个类是否有父类(除了Object类,所有类默认都继承Object类),是否继承了final类,是否实现了必须实现的方法,类中的字段是否正确(不和父类冲突)
-
字节码验证
主要是验证类中的方法在逻辑上是否正确,例如是否不会危害JVM,以及方法中的类型转换是否正确等
-
-
准备
在此阶段,JVM会给静态变量赋值(该变量对应类型的默认值),例如
int
类型,那么赋值为0。如果是常量,那么在当前阶段,就会为常量直接赋值
静态变量真正的赋值需要等到初始化阶段才会执行,因为静态变量的赋值需要等待类构造器的执行,在编译阶段,编译器会将所有的静态变量赋值的操作收集起来,并放到类构造器中。
另:类构造器不能创建,是编译器创建的,并且由虚拟器来调用执行,并且在执行子类的之前,一定是先执行父类的,执行顺序也是由虚拟机保证
-
解析
解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。
- 符号引用:符号就是一个标识符,对引用的变量还没有分配内存,所以在编译阶段并不知道真实的内存地址,所以需要通过符号引用来作为标识
- 直接引用:直接引用即直接指向目前内存地址的指针,编译该对象已经在内存中分配内存地址
在
javac
的编译过程中,会有一个步骤叫做填充符号表,该操作就是将引用的对象填充到一个key-value的关联表中,通过记录符号表的形式来标识引用关系,所以需要在类加载的解析阶段将符号引用替换为直接引用 -
初始化
初始化阶段,是类创建完成的阶段,表示该类已经创建完成,可以创建实例对象使用。在该阶段,会对静态变量进行真正的赋值操作,以及执行静态代码块
在初始化阶段,JVM会执行类构造器(),
<clinit>()
方法中包含了对静态变量赋值操作以及对调用静态代码块的操作。类构造器是在javac编译阶段由虚拟机生成,并且由虚拟机来进行调用,无需显示的指定调用父类(),虚拟机会优先调用父类的(),所以表示父类的静态代码块优先子类执行。
如果没有对静态变量的操作和定义静态代码块,那么编译阶段就不会产生类构造器()
虚拟机会保证一个类的()方法只执行一次,在多线程的环境下正确的加锁、同步。一个类加载器下,一个类只会被加载一次
-
内存分配过程
当类被加载到内存中,如果使用
new
创建对象,那么就会创建这个类的实例对象,首先会为该对象分配内存空间,那么对于JVM来讲,分配内存空间有两种方式-
指针碰撞
当堆内存是规整的,使用过的内存在一边,没有使用过的内存在另一边,中间使用一个指针作为分界点,当新分配对象的时候,将指针向空闲内存的边界进行移动和当前需要的对象内存大小相等的位置。
-
空闲列表
当堆内存不是规整的,会有多个内存碎片,那么虚拟机就需要维护一个列表,记录可用的内存,当对新对象分配内存的时候,就会从列表中找到足够大的内存空间进行分配。
选择哪种分配方式是由Java堆内存是否规整来决定的,如果堆内存规整那么就使用指针碰撞,如果堆内存不规整,那么就使用空闲列表。
Java堆内存是否规整,是由当前JVM使用的GC收集器决定,因为不同的垃圾收集器采用的不同的算法,会导致堆内存是否会产生内存碎片,而堆内存又是主要的垃圾收集区域
CMS收集器采用的标记清除算法,会产生内存碎片,所以采用的是空闲列表
Serial、ParNew、Parrallel等采用的是复制算法,Serial Old、Parallel Old等采用的是标记-整理算法,也不会产生内存碎片,所以采用的是指针碰撞
-
彩蛋
-
为什么父类的静态代码块优先子类静态代码块执行?
在类加载的过程中,静态代码块在准备阶段执行,由类构造器调用执行,并且由JVM保证类构造器的调用顺序,保证类加载的顺序,在调用子类的构造器之前一定先执行的是父类的类构造器,所以父类的静态代码块会在子类的静态代码块之前执行