JVM学习总结(一):Java内存结构

前言

前提:本系列文章未明确指明默认都是基于Oracle HotSpot虚拟机以及JDK1.8】

最近在学习JVM,发现JVM的体系非常庞大,内容错综复杂,往往记住下篇,忘了上篇,所以想通过一系列的文章来记录自己的学习心得,做个学习复盘,一是为了梳理知识点,归纳总结,二是为了分享交流,如有错误之处还望指出。好了废话不多说,直接上正菜:

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

JVM 作为一个虚拟化的计算机,除了要虚拟指令之外,最重要的就是要虚拟化内存,这个虚拟化内存就是我们接下来要讲的 Java 内存结构。

正文

Java 引以为豪的就是它的自动内存管理机制。相比于 C++的手动内存管理、复杂难以理解的指针等,Java 程序写起来就方便的多。所以要深入理解 JVM 必须理解内存虚拟化的概念。可以这样理解:JVM运行时,会向操作系统申请一定大小的内存,这部分内存并未被真实使用,操作系统只是将它虚拟化出来交给JVM来管理,在JVM规范中,规定了这部分内存的一整套管理机制,这就是JVM的自动化内存管理机制。
根据Java使用的内存区域,可以将Java内存结构分为运行时数据区直接内存两部分,其中运行时数据区是被JVM虚拟化出来的那部分内存,是JVM直接管理的内存区域;而直接内存是没有被虚拟机化的操作系统上的其他内存(比如操作系统上有 8G 内存,被 JVM 虚拟化了 3G,那么还剩余 5G, JVM 是借助一些工具使用这 5G 内存的,这个内存部分称之为直接内存)

一、运行时数据区(JVM管理的内存区域)

根据《Java虚拟机规范(Java SE 7版)》的规定,Java虚拟机管理的内存包括程序计数器虚拟机栈本地方法栈方法区五个区域,统称运行时数据区
在这里插入图片描述
其中,方法区是所有线程共享的,程序计数器虚拟机栈本地方法栈线程私有的。

1、程序计数器(Program Counter Register)

1.1 定义

程序计数器是一块较小的内存空间,可看作当前线程正在执行的字节码的行号指示器
如果当前线程正在执行的是

  • Java方法
    程序计数器记录的就是当前线程正在执行的字节码指令的地址
  • 本地方法
    那么程序计数器值为undefined(此时,由操作系统层面的程序计数器来记录本地代码的执行的地址)
1.2 作用(为什么需要程序计数器)

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。这样必然导致线程经常中断或恢复,如何保证分毫无差呢?
每个线程在创建后,都会产生自己的程序计数器,用来存放执行指令的偏移量和行号指示器等,这样便能确保线程每次切换时都能延续上次的执行位置继续执行。

程序计数器有两个作用

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
1.3 特性
  • 一块较小的内存空间
  • 线程私有——每条线程都有一个独立的程序计数器。
  • 和线程同生共死——生命周期随着线程的创建而创建,随着线程的结束而死亡。
1.4 异常

JVM中唯一一个不会出现OOM的内存区域

2、虚拟机栈(Java Virtual Machine Stacks)

2.1 定义

虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧
正在执行的方法称为当前方法
栈帧是方法运行的基本结构
在执行引擎运行时,所有指令都只能针对当前栈帧进行操作
在这里插入图片描述
虚拟机栈通过压/出栈的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另一个栈帧上,在执行的过程中,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定
栈帧是虚拟机栈中存放的元素,主要包括局部变量表操作数栈动态连接方法返回地址

  • 局部变量表
    局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。
    局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
  • 操作数栈
    存放 java 方法执行的操作数的,它就是一个初始状态为空的桶式结构栈,操作数栈,就是用来操作的,操作的的元素可以是任意的 java 数据类型。
    操作数栈本质上是 JVM 执行引擎的一个工作区,也就是方法在执行,才会对操作数栈进行操作,如果代码不执行,操作数栈其实就是空的。
  • 动态连接
    每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态连接

JVM-虚拟机栈之动态链接

  • 返回地址
    正常返回(调用程序计数器中的地址作为返回)、异常的话(通过异常处理器表<非栈帧中的>来确定)
    无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧
    退出可能有三种方式:
    (1)返回值压入,上层调用栈帧
    (2)异常信息抛给能够处理的栈帧
    (3)PC计数器指向方法调用后的下一条指令

栈帧执行对内存区域的影响

  • 示例代码
public class Person {
    public  int work()throws Exception{
        int x =1;
        int y =2;
        int z =(x+y)*10;
        return  z;
    }
    public static void main(String[] args) throws Exception{
        Person person = new Person();//person 栈中--、  new  Person  对象是在堆
        person.work();

        person.hashCode();

    }
}
  • 对Person.class进行反汇编:javap -c Person.class,代码如下
    在这里插入图片描述
    字节码指令可参考这篇文章:java虚拟机 JVM字节码 指令集 bytecode 操作码 指令分类用法 助记符

  • 代码分析
    1、首先执行main()方法,main()-栈帧入栈,接着在main方法中调用work()方法,work()-栈帧入栈,这里重点分析work方法,如图所示
    在这里插入图片描述
    通过javap -c Person.class,可以知道work方法中局部变量表的0号位置存放的是work()方法所属对象的类的自身引用
    在这里插入图片描述
    2、执行int x =1,该语句执行了iconst_1和istore_1两个操作
    (1)iconst_1:将int类型的常量 1 加载到操作数栈(只在操作数栈中定义了一个常量1)
    在这里插入图片描述
    (2)istore_1:将操作数栈中的栈顶元素(这里是常量1)存储到局部变量表中序号为1的位置(赋值操作)
    在这里插入图片描述
    3、int y =2,该语句执行了iconst_2和istore_2两个操作
    (1)iconst_2:将int类型的常量 2 加载到操作数栈(只在操作数栈中定义了一个常量2)
    在这里插入图片描述
    (2)istore_2:将操作数栈中的栈顶元素(这里是常量2)存储到局部变量表中序号为2的位置(赋值操作)
    在这里插入图片描述
    4、int z =(x+y)*10,该语句先后执行了iload_1、iload_2、iadd、bipush 10、imul和istore_3 六个操作
    (1)iload_1和iload_2:分别将局部变量表中下标为1和下标为2的两个元素加载到操作数栈
    在这里插入图片描述
    (2)iadd:首先将操作数栈中的所有元素出栈,然后由执行引擎执行相加操作,最后将执行结果压入操作数栈
    在这里插入图片描述
    将结果3压入到操作数栈
    将结果3压入操作数栈
    (3)bipush 10 : 将常量10加载到操作数栈
    在这里插入图片描述

(4)imul :首先将操作数栈中的所有元素出栈,然后由执行引擎执行相乘操作,最后将执行结果压入操作数栈
在这里插入图片描述

(5)istore_3:将操作数栈中的栈顶元素(这里是常量2)存储到局部变量表中序号为3的位置(赋值操作)
在这里插入图片描述
5、return z,该语句执行了iload_3和ireturn两个操作
(1)iload_3:将局部变量表中下标为3的元素加载到操作数栈
在这里插入图片描述
(2)ireturn:执行引擎执行方法返回的相关操作

2.2 作用

在 JVM 运行过程中存储当前线程运行方法所需的数据,指令、返回地址。

2.3 特性
  • 先进后出(FILO)的数据结构
  • 线程私有,生命周期和线程相同
  • 虚拟机栈的大小缺省为 1M,可用参数 –Xss 调整大小,例如-Xss256k。
2.4 异常

在Java虚拟机规范中,对虚拟机栈这个区域规定了两种异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
  • 如果虚拟机栈可以动态扩展 ,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常

3、本地方法栈(Native Method Stack)

本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。

在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

3.1 特性
  • 线程私有
3.2 异常

与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

4、Java堆(Java Heap)

Java堆的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。

4.1 特性
  • Java堆是Java虚拟机所管理的内存中最大的一块
  • Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
  • Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”
  • Java堆物理上不一定要连续,只需要逻辑上连续即可,就像磁盘空间一样.
  • Java堆的大小既可以固定也可以扩展,但主流的虚拟机堆的大小是可扩展的(通过-Xmx和-Xms控制)
4.2 异常

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

5、方法区

5.1 定义

Java虚拟机规范中定义方法区是堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的是与Java堆区分.

5.2 作用

用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

5.3 特性
  • 线程共享
    方法区是堆的一个逻辑部分,因此和堆一样,都是线程共享的.整个虚拟机中只有一个方法区.
  • 内存回收效率低
    Java虚拟机规范对方法区的要求比较宽松,可以不实现垃圾收集.
    方法区中的信息一般需要长期存在,回收一遍内存之后可能只有少量信息无效.
    对方法区的内存回收的主要目标是:对常量池的回收和对类型的卸载
5.4 异常

根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

5.5 运行时常量池
5.5.1 定义

运行时常量池是方法区的一部分.

我们知道,.java文件被编译之后生成的.class文件中除了包含:类的版本、字段、方法、接口等信息外,还有一项就是常量池。常量池中存放编译时期产生的各种字面量和符号引用,.class文件中的常量池中的所有的内容在类被加载后存放到方法区的运行时常量池中。
PS:int age = 21;//age是一个变量,可以被赋值;21就是一个字面值常量,不能被赋值;
int final pai = 3.14;//pai就是一个符号引用,一旦被赋值之后就不能被修改。

5.5.2 可能抛出的异常

运行时常量池是方法区的一部分,所以会受到方法区内存的限制,因此当常量池无法再申请到内存时就会抛出OutOfMemoryError异常.

5.5.3 小结
  • 我们一般在一个类中通过public static final来声明一个常量。这个类被编译后便生成Class文件,这个类的所有信息都存储在这个class文件中。
  • 当这个类被Java虚拟机加载后,class文件中的常量就存放在方法区的运行时常量池中。
  • 当运行时常量池中的某些常量没有被对象引用,同时也没有被变量引用,那么就需要垃圾收集器回收。
5.6 元空间

在 HotSpot 虚拟机、Java7 版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在 JVM 的非堆内存中,而 Java8 版已经将方法区中实现的永久代去掉了,并用元空间(class metadata)代替了之前的永久代,并且元空间的存储位置是本地内存。
方法区大小设置的参数:
jdk1.7 及以前(永久代):-XX:PermSize(初始值);-XX:MaxPermSize(最大值);
jdk1.8 以后(元空间):-XX:MetaspaceSize(初始值); -XX:MaxMetaspaceSizejdk(最大值)
1.8 以后大小就只受本机总内存的限制(如果不设置参数的话)
JVM 参数参考:Java官方文档

在JDK8里,Perm 区所有内容中

  • 字符串常量移至堆内存
  • 其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间
    在这里插入图片描述比如上图中的Object类元信息、静态属性System.out、整型常量000000等
    图中显示在常量池中的String,其实际对象是被保存在堆内存中的。

Java8 为什么使用元空间替代永久代,这样做有什么好处呢?

  • 官方给出的解释是:移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit没有永久代,所以不需要配置永久代。

  • 永久代内存经常不够用或发生内存溢出,抛出异常 java.lang.OutOfMemoryError: PermGen。这是因为在
    JDK1.7 版本中,指定的 PermGen 区大小为8M,由于 PermGen 中类的元数据信息在每次 FullGC的时候都可能被收集,回收率都偏低,成绩很难令人满意;

  • 还有为 PermGen 分配多大的空间很难确定,PermSize 的大小依赖于很多因素,比如,JVM 加载的 class总数、常量池的大小和方法的大小等。

二、直接内存(堆外内存)

1 定义

直接内存有一种更加科学的叫法,堆外内存。
JVM 在运行时,会从操作系统申请大块的堆内存,进行数据的存储;同时还有虚拟机栈、本地方法栈和程序计数器,这块称之为栈区。操作系统剩余的内存也就是堆外内存。

2 特性

  • 直接内存不是虚拟机运行时数据区的一部分,也不是 java 虚拟机规范中定义的内存区域;
  • 如果使用了 NIO,这块区域会被频繁使用,在 java 堆内可以用directByteBuffer 对象直接引用并操作;

3 异常

  • OutOfMemoryError
    这块内存不受 java 堆大小限制,但受本机总内存的限制,可以通过-XX:MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常。

后语

本文是笔者对JVM学习总结的系列文章的开篇,主要是从整体上概括了Java的内存结构,后续章节还会深入探讨Java的内存结构,敬请期待~~

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值