Java内存模型大揭秘

注:本文针对jdk1.8

在看本文章之前,大家最好对Java的类加载机制有一些了解,可以看我的这篇文章JVM--类加载机制全面大解析。这篇文章的目的就是要让大家清楚,在Java的体系下,围绕对象来说是怎么工作的?怎么完成计算?对象在内存中是什么样的?话不多说,先上个图 这个就是整个Java内存模型的结构图,我们会对每一个部分进行说明,希望看完这篇文章之后,大家能对Java的内存结构有比较清晰的认识。我们先从线程私有的地方说起。

虚拟机栈

我们平时工作中发现,如果没有公共变量参与的情况下,每个线程之间的运算是毫不相干的,多个人调用一个接口也会得到他们自己的结果,我们也称之为线程私有。那么线程私有部分都包含什么呢?也就是什么东西不能共用呢?我们带着问题来继续阅读。 如上图,就是虚拟机栈,我们也可以称之为线程栈,生命周期同线程。由多个栈帧组成。栈帧是什么呢?通常情况下,每调用一个方法就会在虚拟机栈中推送一个栈帧,栈的原理我们是知道的,先进后出。举个例子,a方法调用b方法,b方法调用c方法。所以就是a->b->c。c执行完,弹出,继续b,b执行完弹出,执行a。看,a最先调用,但是最后弹出,多个栈帧组成一个虚拟机栈。所以我们要了解虚拟机栈,就要知道栈帧的原理是什么。主要包含四个部分:局部变量表,操作数栈,动态链接,方法返回地址

局部变量表

局部变量表,顾名思义,主要存储了编译期间可知的变量的数据类型,比如int,float,double,对象引用,以及方法参数,名称,和值等,比如计算a+b,我就得存储a的类型,值,作用域等信息。

操作数栈

这个主要就是中转站的作用,存中间的一些计算结果,毕竟我们的方法可能经历很多计算步骤。就像我们解数学题一样,中间过程一步一步生成的各种结果。

到这我们发现如果简单的方法逻辑完全可以解决了,但是我们大多数的情况下都需要方法嵌套方法,不然的话一个虚拟机栈就一个栈帧了不是。方法调用方法就需要下面这个动态链接了。

动态链接

它的作用就是进行方法调用的时候,将符号引用转为直接引用,然后才能调用方法。

方法返回地址

存储调用方的地址,毕竟你出来了,还得回去呢。

小总结一下: 虚拟机栈就是完成线程方法调用的容器。现在我们通俗的总结一下整个过程。在类加载的解析阶段的时候会将类的符号引用转为直接引用。这是是啥呢,类文件的常量池中存在着各种变量方法的符号引用,我举个小例子说明一下,不太严谨但是容易理解,比如变量a,符号就是a,b方法就是b,但是这个能用吗?虚拟机搞不懂你这个东西。虚拟机需要的是字节码,然后解释执行。所以我们需要一个过程将符号转为字节码,这就有了符号引用转为直接引用,而直接引用就是存储的元空间中的类或者类方法的地址,比如a变量的地址,b方法的地址,然后根据地址找到类信息,之后通过类加载器读取文件获取字节码。

再说,举个例子a类调用b类,在a类进行类加载的时候,如果b类没有进行类加载,会进行b类的类加载,同时会符号转为直接引用,如果b类进行了类加载了,那么就保留符号引用,因为可以通过符号引用找到直接引用了。找就是根据全限定类名找的。所以a类对b类可能是符号引用也可能是直接引用,不一定,所以就需要动态链接来参与,如果是符号引用,就转直接引用调用,所以这个过程是动态的。

每调用一个方法都会推入一个栈帧,所以栈帧随着方法结束而消亡。方法结束通常两种,一种return,一种抛异常。所以这里通常会产生两种异常,一种是StackOverFlowError,超过虚拟机栈最大深度,栈溢出,一种是OutOfMemoryError,无法申请足够的内存,内存溢出异常。

这里包含了很多自己的理解,如果写的有问题,欢迎指正。

本地方法栈

本地方法栈和虚拟机栈的作用是十分类似的,只不过里面的栈帧针对的是native方法,同样包含局部表量表,操作数栈,动态链接和方法返回地址。

程序计数器

从上面我们知道了程序进行计算,计算的中间结果,怎么调用方法执行,但是还缺了最关键的一点,就是执行的顺序,所以程序计数器就是干这个的。它可以认为是当前线程所执行字节码的行号指示器,字节码解释器改变计数器的值来得到下一个需要执行的字节码指令,比如顺序执行,分支,跳转,循环等等。为了保证每一个线程都能正确的执行,所以程序计数器是线程私有的。而且它是唯一一个不会出现OutOfMemoryError的内存区域,生命周期随着线程创建和消亡。

堆是JVM管理的内存空间最大的几块,也是所有线程共享的一块内存区域,几乎所有的对象实例和数组都在这里分配内存。但是随着技术的发展,逃逸分析技术的逐渐成熟,如果某些方法的对象引用没有被返回或者被外面使用(那就是未逃逸),这样的对象可以在栈上直接分配内存,毕竟生命周期在一个方法内。

同时堆也是垃圾收集器管理的主要区域,对于垃圾收集器我会单独抽出一篇文章去解析。

字符串常量池

字符串常量池是JVM为了提升性能和减少内存消耗对字符串专门开辟的内存区域,毕竟字符串可以说是我们最常用的一个数据结构了,所以这样做的目的就是为了避免字符串的重复创建,底层是一个C++的实现,可以认为是一个固定大小的HashTablekey是字符串,value是字符串对象的的引用。jdk1.7之前,字符串常量池在永久代里面,1.7的时候字符串常量池和静态变量移动到了堆中。主要目的就是为了高效GC,毕竟如果放在方法区的话,只有FullGC的时候才会去回收。

元空间(方法区的实现)

方法区其实是一种抽象概念,不同虚拟机实现方式是不一样的,我说的都是针对HotSpot虚拟机。元空间的前身是永久代,没错就是堆中的永久代,但是为了考虑后续发展,就放弃永久代,转而使用本地内存实现方法的计划。元空间主要存的就是类的元数据,如类信息,字段信息,方法,常量,JIT编译后的代码缓存等,相当于把字节码文件加载到内存中了。使用本地内存就代表着可以加载更多的类,空间的最大上限就是机器内存上限。

运行时常量池

运行时常量池存储着类加载期间产生的各种字面量和符号引用也就是我们在类加载机制那里看到的ClassFile的常量池内容,全都加载进来。

直接内存

顾名思义就是在本地内存上分配空间,比如NIO,它可以直接使用Native函数库直接分配堆外内存,然后通过存储在Java堆中的DirectByteBuffer对象作为这个内存的引用进行操作。

至此,Java虚拟机的内存情况我就都介绍完了,下面我总结一下,以a类方法调用b类方法为例 总结:

  1. 类加载的时候不会全部加载,大多数情况下都是使用时加载,加载的过程都在上面写了,有个关键点就是类信息和运行时常量池会放在元空间中。
  2. 当a方法执行的时候,会先检查类是否加载过,如果没有加载,就先加载,同时发现会调用b类,如果b类没有加载会加载b类。
  3. 然后为调用a方法的线程创建一个虚拟机栈,本地方法栈和程序计数器,并推入一个栈帧,同时生成局部变量表,操作数栈,动态链接,方法返回地址。开始根据虚拟机栈的内容进行计算过程
  4. 当调用b类的方法的时候如果ClassLoder中存储的Class对象对b类还是符号引用,那么就需要转为直接引用,然后找到存储在元空间的类信息,方法信息等。
  5. 然后ClassLoader会根据类信息和方法信息去文件或网络中寻找对应的字节码,然后交给解释器解释执行。 然后就不断的根据这个过程得到结果。

对象创建过程

对于虚拟机的内存和程序执行过程有了了解之后,我们再说下对象的创建过程。直接上图 这里我主要说一下内存分配这里。内存分配有两种方法,一种是指针碰撞,一种是空闲列表。这两种方式分别是针对内存规整和内存不规整的情况,而内存是否规整取决于采用哪种垃圾收集器。

指针碰撞: 这种方式的原理是将内存根据一个指针分割一边使用过的,一边没有使用的,然后指针向着没有使用过的内存移动到需要的内存大小就可以了,使用此方式的垃圾收集器为:SerialParNew 空闲列表: 这种方式的原理是虚拟机维护一个空闲内存的列表,然后找足够大的内存块划分给实例对象,然后更新列表记录。使用此方式的垃圾收集器为:CMS

当然在对象分配内存的时候存在并发问题,也就是竞争同一块内存空间。虚拟机是采用下面的方式来保证线程安全的。

首先,使用TLAB区域,也就是为每一个线程预先在Eden区分配一块内存,然后JVM分配内存的时候,现在这个区域上面分配,如果实例对象所需的内存大于TLAB或者TLAB内存耗尽的时候,就采用CAS的方式进行内存分配。如果所选内存竞争失败,那就重新选择其他内存空间进行CAS,直到成功。

对象的内存结构

上面我们说完了类加载,也说完了对象的创建过程,也分析了Java的内存模型,下面再说一下实例对象的内存结构,都存了什么东西,先上个图

对象头: 对象头中包含两种数据,一个是MarkWord,一种是类型指针,在上面我也说了MarkWord存储的一些知识,但是细节我想留在讲多线程的时候再说,那么会更加透彻的理解对象头的作用。第二种数据就是类型指针,就是指向元空间中类的元数据信息的指针。

实例数据: 实例数据存储了实例对象变量的信息和一些引用信息,比如对象的引用,字符串的引用等等。

对齐填充: 对齐填充就是字面意思,就是为了保证对象的大小是8个字节的整数倍。这样做的目的可以使得内存的分配和回收更加的规整,同时根据现代CPU缓存的一些理论,8个字节的整数倍可以和它更好的契合,提升程序的性能。

对象引用方式

上面我们说了实例数据中可能会包含一些引用信息,比如对象引用,那么JVM是怎么根据引用找到实例对象数据的呢?直接上个图

两种方式,一种是句柄,一种是直接指针,从图上我们就能看到两个的区别。句柄方式的好处就是目标实例对象修改的时候只需要改实例对象数据就行,不需要改引用关系,相当于有个中间桥梁,但是需要经历两次查找。直接指针只需要一次,但是引用关系需要更改。而JVM采用的是句柄的方式。

相关推荐
Chrikk16 分钟前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*19 分钟前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue19 分钟前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man22 分钟前
【go从零单排】go语言中的指针
开发语言·后端·golang
测开小菜鸟23 分钟前
使用python向钉钉群聊发送消息
java·python·钉钉
P.H. Infinity1 小时前
【RabbitMQ】04-发送者可靠性
java·rabbitmq·java-rabbitmq
生命几十年3万天1 小时前
java的threadlocal为何内存泄漏
java
caridle2 小时前
教程:使用 InterBase Express 访问数据库(五):TIBTransaction
java·数据库·express
^velpro^2 小时前
数据库连接池的创建
java·开发语言·数据库
苹果醋32 小时前
Java8->Java19的初步探索
java·运维·spring boot·mysql·nginx