前言
本文是JVM系列的内存模型篇,参考资料为《深入理解Java虚拟机》,本文章将会以HotSpot 虚拟机为介绍基础。
1.JVM简单介绍
Java Virtual Machine是运行Java程序的基础,JVM基于C、C++实现,JVM有很多种类,但是这些虚拟机都必须按照《Java虚拟机规范》来进行实现。目前JDK使用的是HotSpot虚拟机。
2.JVM内存模型
根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域
- 程序计数器
- Java虚拟机栈
- 本地方法栈
- 方法区
- 堆
分布如下图:
3.程序计数器
程序计数器是Java中占用内存比较少的一个区域,他的作用是记录当前线程所执行的字节码的行号指令,通俗的理解就是代码执行到哪里了。
我们很容易思考到,在多线程中,是发生线程切换这种情况的,那么一个线程被切换后,它的状态就需要被记录到上下文中,方便线程能正确执行到原来的位置,那么为了记录这个位置,就需要程序计数器来进行实现
为了线程切换后能恢复到正确的执行位置,每个线程都需要一个独立程序计数器,各个线程之间计数器互不影响,独立存储。因此它也是“线程私有”的内存
这片区域也是唯一一个在《Java虚拟机规范》中没有任何OutOfMemoryError情况的区域。
4.Java虚拟机栈
Java虚拟机栈,以“栈”命名的,在内存模型中,基本都是用来处理方法的,所以Java虚拟机栈是用来处理Java语言实现的方法的。同理的,这个栈也是线程私有的。他的生命周期与线程生命周期一样长。
一个线程在调用方法的时候,会在虚拟机栈中,创建一个栈帧,这个栈帧会存放局部变量表、操作数栈、动态连接、方法出口等信息。
栈帧包含以下内容:
-
局部变量表: 栈帧用于存储方法的局部变量,表中存放了编译期可知的基本数据类型和对象引用(对象引用指针或者句柄)。这些局部变量在方法调用时分配内存空间,并在方法调用结束后被释放。
-
操作数栈: 栈帧还包含一个操作数栈,用于存储方法执行时的操作数。当方法需要进行计算或操作时,操作数会被入栈或出栈。
-
动态链接: 栈帧包含指向运行时常量池中当前方法引用的指针,用于在方法中访问其他类或方法。
-
方法出口: 当方法调用完成后,程序需要返回到方法调用的地方继续执行。栈帧包含方法返回地址,用于记录返回的位置。
额外提一嘴的是当Java虚拟机栈的深度被方法调用填满的时候,就会出现StackOverFlowError;如果栈的大小动态扩展到没办法扩展的时候,会报OOM(OutOfMemoryError)的错误。
5.本地方法栈
这个栈和Java虚拟机栈是一样的功能,但是作用的对象不一样,Java虚拟机栈对应的是Java方法,而本地方法栈对应的是被Native标志的方法,这类方法一般都是C、C++代码。其他东西基本和Java虚拟机栈一致。
6.方法区
方法区与Java堆一样,是各个线程共享的内存区域,这块区域是用来存储已经被加载的类元信息,这些信息包含:类型信息、常量、静态变量、即使编译后的代码缓存等信息。
6.1永久代与元空间
早在JDK1.8以前,方法区使用的永久代的实现方式,而在1.8后才正式确定使用元空间。那么二者实现上有什么区别呢????
最大的区别就是前者是使用的虚拟机内存,后者使用了直接内存,也就是说永久代的内存大小受JVM限制,而元空间内存大小受真实机子内存大小限制,明显后者内存大小更大,前者更容易OOM。
在方法区使用元空间后,字符串常量池也从方法区移动到了堆内存中。
6.2运行时常量池
提到方法区,就不得不提到一个叫运行时常量池的东西,它也是方法区的一部分。
一个Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
7.堆
堆内存是整个虚拟机中最大的一块,这块区域是被线程共享的,这块对象就是用来在程序执行时,大部分对象存放的地方(还有极小一部分可能会发生逃逸分析,在栈上创建和销毁)。
堆这块区域,也是最容易发生OOM的地方,原因可想而知,公共的地方,大家都来这里放东西,时间一长,没有空间也很正常,所以这块区域也是发生GC(Garbage Collected)频率最高的一个场所。(具体GC流程,下篇文章会详细介绍)
7.1 对象创建
堆中的对象(普通对象)创建过程也是比较讲究的,下面我们带着问题,一步一步理解这个过程
首先,如何创建?
很简单的,new关键字
那么问题又来了,对象创建依赖的信息从哪里来?
当Java遇到一条字节码new指令的时候,首先将去检测这个指令能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那么必须先执行相应的类加载(双亲委派模型)。
对象依赖信息得到后,内存大小该如何划分分配?
当一个类被加载之后,相应的对象创建所需的内存大小也就能被确定了。那么要在堆中创建对象,就需要划分空间,JVM中有两种划分空间的方式,分别是“指针碰撞”和“空闲列表”
- 指针碰撞
假设Java堆中内存分配绝对规整,使用过的和未使用的分成两边,只需要在边界设置指针,这个指针只需要挪动和对象大小一样的距离即可,这种就是指针碰撞- 空闲列表
假如Java堆内存并不规整,使用过的和未使用的都混在一起,这种情况,要分配内存就只能维护一个列表,这个列表记录了哪些内存可以使用,分配内存就需要在表中查找到足够到的区间进行分配即可,这就是空闲列表
并发下,对象创建的内存分配安全如何得到保证?
为我们所知的,堆内存是一个线程共享的,这就意味着,我们堆在划分内存大小的时候,可能会出现线程安全问题。可能出现线程1在给A分配大小的时候,还没来得及修改指针,但是线程2在创建B时,使用了这个指针,就导致了内存数据被改写了。解决这个问题有两个方式
- 加锁同步
实际实现中虚拟机是采用CAS+失败重试的方式保证更新操作的原子性- TLAB(Thread Local Allocation Buffer,本地缓冲区),也和ThreadLocal一样,给每个线程各自划分好区域,线程要创建对象,就在这个区域内创建就行,如果TLAB使用完了才需要进行同步锁定分配对象。如果JVM要使用TLAB,可以通过-XX:+/-UseTLAB参数来设定
实际上,内存分配成功之后,虚拟机还会对分配到的内存空间(不包括对象头)进行初始化工作,零值处理。这步操作是为了保证对象实例字段在Java代码中可以不赋值就能直接使用。
经历以上步骤,对象创建后,对象还需要设置什么?
需要设置“对象属于哪个类的实例”、“类的元数据信息“”、“对象hash码(实际调用Object::hashCode才会生成)”、“GC分代年龄”,这些信息都被描述在对象头中
最终
在上面工作都完成后,看似一个对象已经被创建了,但实际上,整个生命过程还差一步,即初始化,构造函数中的初始化工作还没有被真正执行,也就是 < init > ()方法,所以值都是默认为零值的,所以当构造函数执行完成后,一个对象就被完成创建了。
7.2 对象的内存布局
在了解一个对象的创建过程后,我们来看看,一个对象内部布局是如何的,直接看下图:
对象头:这部分包含了两部分信息
- 第一部分:HashCode、GC分代年龄、锁状态标记、线程持有的锁、偏向锁ID、偏向锁时间戳等信息等,这部分信息官方称之为:Mark Word,这部分数据在32位和64位虚拟机(未开启指针压缩)中分别占用32bit和64bit。
- 第二部分:类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定这个对象是哪个类的实例。如果是数组对象,对象头中还会记录数组长度,如果不是则无记录。
实例数据:这部分数据是对象真正存储的有效信息
对齐填充:这部分的内容不是必然存在的,也没有特殊含义,这部分的主要作用就是保证这个对象大小是8字节的整数倍,差多少,尽可能补多少。
JVM执行流程
-
代码编译:Java源代码通过Java编译器(javac)编译成字节码文件(.class文件)。
-
类加载:JVM的类加载器将字节码文件加载到内存中,并进行校验、准备、解析等处理。
-
内存分配:JVM为加载的类分配内存,包括方法区、堆、栈等。
-
初始化:JVM对类进行初始化,包括静态变量的赋值、静态代码块的执行等。
-
执行:JVM开始执行字节码指令,逐行读取字节码文件并执行。这个执行过程交给执行引擎将字节码翻译成CPU指令交给操作系统去执行
-
…