Java虚拟机 - JVM的内存结构

本次分享主要从Java内存模型、Java内存结构和垃圾回收三个方面讲解Java虚拟机
本章是第二节:内存结构



Java内存结构

上一章我们将了Java的内存模型,本章开始讲解Java内存结构。


一、虚拟机

从软件层面屏蔽不同操作系统在底层硬件指令上的区别。这也就是Java跨平台的由来。

同样的java代码在不同平台生成的机器码肯定是不一样的,因为不同的操作系统底层的硬件指令集是不同的。
同一个java代码在windows上生成的机器码可能是0101,在Linux上生成的可能是1100,那么这是怎么实现的呢?
在Oracle官网上要根据不同操作系统或者位数版本下载不同的JDK,这说明针对不同的操作系统Java虚拟机有不同的实现。

二、虚拟机组成

类加载子系统:负责从文件系统或者网络中加载Class信息
字节码执行引擎:负责执行虚拟机的字节码
垃圾回收系统:可以对Java堆,直接内存和元空间进行回收。垃圾回收是隐式的自动完成的,不用程序手动是释放内存
:Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。堆是所有线程共享的
元空间:存放常量、静态变量、类元信息。元空间是所有线程共享的。Java8之前叫方法区,在Java8版本使用NIO方式使用Native函数库直接分配堆外内存。
:每个虚拟机线程都有私有的Java栈,一个线程的栈是在线程创建时创建的。栈是线程独享的
本地方法栈:作用与栈类似,为虚拟机使用的Native方法服务。常用于Java调用本地C或C++接口。本地方法栈是线程独享的
程序计数器:也成为PC计数器。可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成

三、栈

我们先来讲解栈。请大家看这段代码,后续的讲解都需要结合这段代码进行

public class Math {
public static int initData = 666;

public static UserEntity user = UserEntity();

public int compute() {
	int a = 1;
	int b = 2;
	int c = (a + b) *10;
	return c;
}
public static void main(String[] args) {
	Math math = new Math();
	math.compute();
	System.out.pringln("test");
}

}

栈是用来存储局部变量的,是线程独有的区域,也就是每一个线程都会有自己独立的栈区域。

每个方法都有自己的局部变量,比如上图程序中main方法中的math,compute方法中的a b c。Java虚拟机为了区分不同方法中局部变量作用域的内存区域,每个方法在运行时都会分配一块独立的栈帧内存区域

我们按照上图程序来简单画一下代码执行的栈内存活动

 1. 执行main方法中的第一行代码,new一个math对象。栈中会分配main()方法的栈帧,并存储math局部变量
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210112104828785.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3NoYW9zaGVuMTAyOQ==,size_16,color_FFFFFF,t_70)

 2. 接着执行compute()方法,那么栈又会分配compute()的栈帧区域。 这里的栈存储数据的方式和数据结构中学习的栈是一样的,先进后出。当compute()方法执行完之后,就会出栈被释放,也就符合先进后出的特点,后调用的方法先出栈。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210112104854718.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3NoYW9zaGVuMTAyOQ==,size_16,color_FFFFFF,t_70)


那么栈帧是什么呢?
![在这里插入图片描述](https://img-blog.csdnimg.cn/2021011209201850.png)
栈帧其实不只是存放局部变量的,它还存放着一些别的东西,主要由四部分组成。分别是**局部变量表**、**操作数栈**、**动态链接**和**方法出口**。

那么要讲这个就会涉及到更底层的原理-字节码。我们先看下上面程序的字节码文件。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210112092105319.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3NoYW9zaGVuMTAyOQ==,size_16,color_FFFFFF,t_70)
上图截取部分字节码。我们可以看出这是一个16进制的文件。我们可以得出一个结论,这个16进制的文件就不是人看的!

我们可以看下红框部分,这四组16进制字节码就是java文件的魔法数,所有的class文件都会以这四组数开头。所以我们校验文件是否为class时,最准确的方法是通过判断字节码的头部魔法数来实现

为了让我们能看懂上面的字节码,我们可以通过jdk自带的javap命令,将上述class文件生成一种更可读的字节码文件

![在这里插入图片描述](https://img-blog.csdnimg.cn/20210112092139588.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3NoYW9zaGVuMTAyOQ==,size_16,color_FFFFFF,t_70)
![在这里插入图片描述](https://img-blog.csdnimg.cn/2021011209214842.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3NoYW9zaGVuMTAyOQ==,size_16,color_FFFFFF,t_70)
此时的jvm指令码就清晰很多了,大体结构是可以看懂的,类、静态变量、构造方法、compute()方法、main()方法
其中方法中的指令还是有点懵,我们举例compute()方法来说明一下
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210112092211545.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3NoYW9zaGVuMTAyOQ==,size_16,color_FFFFFF,t_70)
这几行代码就是对应的我们代码中compute()方法中的四行代码。大家都知道越底层的代码,代码实现的行数越多,因为他会包含一些java代码在运行时底层隐藏的一些细节原理。
这个jvm指令官方也是有手册可以查阅的,网上也有很多翻译版本,大家如果想了解可自行百度。
我们结合图讲解指令与栈帧
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210112092240733.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3NoYW9zaGVuMTAyOQ==,size_16,color_FFFFFF,t_70)
0.将int类型常量1压入操作数栈
1.将int类型值存入局部变量1
    局部变量1,在我们代码中也就是第一个局部变量a,先给a在局部变量表中分配内存,然后将int类型的值,也就是目前唯一的一个1存入局部变量a
2.将int类型常量2压入操作数栈
3.?将int类型值存入局部变量2
    这两行代码就跟前两行代码类似了
4.从局部变量1中装载int类型值
5.从局部变量2中装载int类型值
    这两个代码是将局部变量1和2,也就是a和b的值装载到操作数栈中
6.执行int类型的加法
    iadd指令会将操作数栈中的1和2依次从栈底弹出并相加,然后把运算结果3在压入操作数栈底
7.将一个8位带符号整数压入栈
    这个指令就是将10压入栈
8.执行int类型的乘法
    这里就类似上面的加法了,将3和10弹出栈,把结果30压入栈
9.将int类型值存入局部变量3
    这里大家就不陌生了吧,和最开始的几步是一样的,将30存入局部变量3,也就是c
10.从局部变量3中装载int类型值
    与第四步和第五步是一样的
11.返回int类型值
    将操作数栈中的30返回

到这里就把我们compute()方法讲解完了,讲完有没有对局部变量表和操作数栈的理解有所加深呢?说白了赋值号=后面的就是操作数,在这些操作数进行赋值,运算的时候需要内存存放,那就是存放在操作数栈中,作为临时存放操作数的一小块内存区域。

接下来我们再说说方法出口

当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理。
方法出口说白了就是方法执行完了之后要出到哪里,那么我们知道上面compute()方法执行完之后应该回到main()方法第二行那么当main()方法调用compute()的时候,compute()栈帧中的方法出口就存储了当前要回到的位置,那么当compute()方法执行完之后,会根据方法出口中存储的相关信息回到main()方法的相应位置。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的程序计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210112092330454.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3NoYW9zaGVuMTAyOQ==,size_16,color_FFFFFF,t_70)
方法出口说白了就是方法执行完了之后要出到哪里,那么我们知道上面compute()方法执行完之后应该回到main()方法第二行那么当main()方法调用compute()的时候,compute()栈帧中的方法出口就存储了当前要回到的位置,那么当compute()方法执行完之后,会根据方法出口中存储的相关信息回到main()方法的相应位置。

那么main()方同样有自己的栈帧,在这里有些不同的地方我们讲一下。

![在这里插入图片描述](https://img-blog.csdnimg.cn/20210112092449509.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3NoYW9zaGVuMTAyOQ==,size_16,color_FFFFFF,t_70)
我们上面已经知道局部变量会存放在栈帧中的局部变量表中,那么main()方法中的math会存入其中,但是这里的math是一个对象,我们知道new出来的对象是存放在堆中的
那么这个math变量和堆中的对象有什么联系呢?是同一个概念么?
当然不是的,局部变量表中的math存储的是堆中那个math对象在堆中的内存地址
# 四、程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完。
另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

从上面的介绍中我们知道程序计数器主要有两个作用:
1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

结合之前的代码,代码中每个指令码前面都有一个行号,你就可以把它看作当前线程执行到某一行代码位置的一个标识,这个值就是程序计数器的值。

# 五、本地方法栈
和栈所发挥的作用非常相似,区别是:?栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、方法出口。

实际上现在本地方法栈已经用的比较少了,举一个本地方法的例子:我们常用的线程类。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210112092746719.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3NoYW9zaGVuMTAyOQ==,size_16,color_FFFFFF,t_70)
线程类的start()方法,我们在IDEA中点进去看一下start()方法的具体实现
其中底层调用了一个start0()的方法

这个方法没有实现,但又不是接口,是使用native修饰的,是属于本地方法,底层通过C语言实现的,那java代码里为什么会有C语言实现的本地方法呢?
在Java问世之前一个公司的系统99%都是使用C语言实现的,但是Java出现后,很多项目都要转为Java开发,那么新系统和旧系统就免不了要有交互,那么就需要本地方法来实现了,底层是调用C语言中的dll库文件。当然,如今跨语言的交互方式就很多了,比如http接口,webservice等方式,当时并没有这些方式,就只能通过本地方法来实现了。
那么本地方法始终也是方法,每个线程在运行的时候,如果有运行到本地方法,那么必然也要产生局部变量等,那么就需要存储在本地方法栈了。如果没有本地方法,也就没有本地方法栈了。

# 六、元空间
在JDK1.8之前,有一个名称叫做**持久带/永久代**,在JDK1.8之后,oracle官方改名为**元空间**。存放常量、静态变量、类元信息。
JDK1.8之后由NIO在直接内存中开辟存储空间,此部分数据存储并不是在Java虚拟机中。
结合代码我们讲解一下元空间
```c
public class Math {
	public static int initData = 666;
	
	public static UserEntity user = UserEntity();
	
	public int compute() {
		int a = 1;
		int b = 2;
		int c = (a + b) *10;
		return c;
	}

	public static void main(String[] args) {
		Math math = new Math();
		math.compute();
		System.out.pringln("test");
	}
}

这个initData是静态变量,毋庸置疑就是存放在元空间。
这个user就有点不一样了。我们之前说过new出来的对象都是存放在堆中的,那么user又是静态变量存放在元空间。
到这里我们能意识到,栈、堆、元空间之间是有联系的
在这里插入图片描述
栈中的局部变量,元空间中的静态变量,如果是对象类型的话都会指向堆中new出来中的对象
那么图中红色的联系代表什么呢?
我们先来了解一下对象。

大家天天new对象,但是否知道对象在虚拟机中的存储结构呢?我们结合图来看看Java对象实例和Java数组实例在内存中有哪些部分组成

虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

  • 对象头:虚拟机中对象头主要包括两部分信息
    1.Mark Word(标记字段):存储对象自身的运行时(Runtime)数据,如哈希码、GC分代年龄、锁状体标识、线程持有的锁、偏向锁ID、偏向时间戳等。这部分数据的长度在32位和64位虚拟机(未开启压缩指针)中分别为32bit和64bit
    2.Klass Pointer(Class对象指针):存储对象指向它的类元数据的指针,即对象对应的Class的内存地址。虚拟机通过这个指针来确定这个对象是哪个类的实例
    3.Length(数组长度):只在数组对象中存在,用于记录数组长度
  • 实例数据:这部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来
  • 对齐填充:第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

那么我具体看看,那条红色的联系线究竟是什么。我们先把之前栈、堆、元空间的结构展示出来

1.假设栈帧局部变量中存储两个math变量。
2.由于math是new1.假设栈帧局部变量中存储两个math变量。
2.由于math是new出来的对象。所以math和math2的对象都存储在堆中。
3.当对象被new时,都会在对象头中存储一个指向类元信息的指针,即Klass Pointer。由于math和math2都是Math.class类new出来的,所以在math和math2对象的头中的Klass Pointer都指向元空间的Math.class

通过前面的讲解,我们可以分析出,Klass Pointer对象指针就是那条红色的联系线出来的对象。所以math和math2的对象都存储在堆中。
3.当对象被new时,都会在对象头中存储一个指向类元信息的指针,即Klass Pointer。由于math和math2都是Math.class类new出来的,所以在math和math2对象的头中的Klass Pointer都指向元空间的Math.class

通过前面的讲解,我们可以分析出,Klass Pointer对象指针就是那条红色的联系线

结合我们之前讲到的栈、堆、元空间的关系和对象组成。我们可以扩展出一个Java对象的创建过程

1.类加载检查
2.分配内存
3.初始化零值
4.设置对象头
5.指向init方法
这部分还有很多细节内容,本次不具体展开讲解。感兴趣的同学可以自己查阅资料学习一下

七、堆

Java内存结构中,我们最后讲讲堆。堆是最重要的一块内存区域。
首先,我们来看看堆的内部结构。
堆内部是由年轻代和老年代两部分组成
在年轻代中又分为Eden(伊甸区)和Survivor(幸存者区),Survivor区又划分为From区和To区(也成为S0区和S1区)

堆中各分区的内存占比是可以根据Java启动参数进行调整的,oracle官方推荐的内存分配占比为:年轻代占堆内存的1/3,老年代占2/3。在年轻代中Eden区占年轻代内存的8/10,From区和To区分别占1/10

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值