摸鱼JVM -运行时数据区

前言

前面介绍过

JVM被分为三个主要的子系统:

  1. 类加载器子系统
  2. 运行时数据区(也就是内存相关)
  3. 执行引擎

JVM

前面我们介绍了JVM的类加载机制, 今天我们则重点聊聊JVM的运行时数据区, 即JVM内存相关知识

关于JVM内存, 有两个比较重要的概念,

这两个概念, 经常会有人搞混, 所以, 顺带来做个梳理.

  • 内存模型

  • 内存结构

什么是内存模型(JMM)?

Java Memory Model, 就是我们常说的JMM;

JMM和JVM内存结构不同, 它只是一个抽象的概念, 描述了一组规则或规范, 这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。

我们知道, Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型.

JMM定义了一些语法集, 这些语法集映射到Java语言中就是volatile、synchronized等关键字.

简而言之, JMM就是为了解决Java多线程对共享数据的读写一致性问题而产生的一种模型!

PS: 关于Java多线程的读写一致性问题的前世今生可以阅读我的另一篇文章你不得不知道的线程安全问题

JMM内存模型可以归纳为下图

内存模型

什么是JVM内存结构?

JVM的内存结构也叫运行时数据区;

JVM中内存通常划分为两个部分, 分别为堆内存与栈内存,栈内存主要用运行线程方法存放本地暂时变量与线程中方法运行时候须要的引用对象地址;

堆内存则存放全部的对象信息.

相比栈内存, 堆内存能够所大的多, 所以JVM一直通过对堆内存划分不同的功能区块, 实现对堆内存中对象管理.

堆内存不够最常见的错误就是OOM(OutOfMemoryError)

栈内存溢出最常见的错误就是StackOverflowError

此外, 也有较为细致的划分;

根据JVM 规范, 定义了五种运行时数据区, 分别是:

  • 程序计数器
  • Java虚拟机栈
  • 本地方法栈
  • Java堆
  • 方法区

这里注意, JVM规范只是一种规范, 而不是具体的实现!

可以这么理解: JVM规范和JVM的关系就是接口和实现类的关系!

JVM规范只是规定了这五种数据区的作用, 并没有规定如何去实现它,所以在不同的JVM中对这五种数据区实现是不同的;

举个例子:

我们常用的HotSpot虚拟机, 在JDK1.8之前对方法区的实现就是永久代!
JDK1.8后又取消了永久代,转而用元空间实现了方法区!

接下来,我们就以HotSpot虚拟机为例来理清JVM的内存结构

程序计数器(线程私有)

程序计数器可以看作是JVM对CPU程序计数器的一种模拟;

它是一块较小的内存空间, 用来存储当前线程的所执行的字节码的行号;

我们知道, Java的多线程是通过线程轮流切换、分配处理器时间片的方式来实现的,
所以在任何一个时刻, 一个CPU的内核只会执行一个线程中的命令;
一旦当前线程的时间片结束然后被挂起, 当又轮到这个被挂起的线程执行的时候, 如何去恢复被挂起前的状态?
这个就是依靠程序计数器,保存当前执行的字节码的位置.

简而言之, 就是个“书签”的功能.

注意以下几点:

  1. 程序计数器是线程私有的, 每个线程都有一个自己的程序计数器.
  2. 如果当前线程执行的是native方法, 则其值为null
  3. 在这块内存空间中不存在任何OutOfMemoryError情况
Java虚拟机栈(线程私有)

Java虚拟机栈描述 java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态链接、方法出口等信息.

每一个方法从调用直至执行完成 的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程.

栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception).

栈帧随着方法调用而创建,随着方法结束而销毁, 无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。

Java虚拟机栈

Java虚拟机栈特点如下:

  1. Java虚拟机栈是线程私有的,它的生命周期与线程相同(随线程而生,随线程而灭)
  2. 栈帧包括局部变量表、操作数栈、动态链接、方法返回地址和一些附加信息
  3. 每一个方法被调用直至执行完毕的过程, 就对应这一个栈帧在虚拟机栈中从入栈到出栈的过程

栈帧结构如下图
image.png

  • 局部变量表

局部变量表也被称之为局部变量数组或本地变量表,是一组变量值的存储空间;

主要用于存储方法参数和定义在方法体内的局部变量这些数据类型.
包括各类基本数据类型、对象引用(reference), 以及returnAddressleixing

局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中, 在方法运行期间是不会改变局部变量表的大小的.

方法嵌套调用的次数由栈的大小决定, 一般来说, 栈越大, 方法嵌套调用次数越多.
对一个函数而言, 他的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间.

局部变量表中的变量只在当前方法调用中有效, 在方法执行时, 虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程; 当方法调用结束后, 随着方法栈帧的销毁,局部变量表也会随之销毁.

  • 操作数栈

操作数栈, 是一个后入先出栈, 主要用于保存计算过程的中间结果, 同时作为计算过程中变量临时的存储空间;

当一个方法开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈默认是空的, 在方法的执行过程中, 根据字节码指令,往栈中写入数据或提取数据;

操作数栈并非采用访问索引的方式来进行数据访问的, 而是只能通过标准的入栈push和出栈pop操作来完成一次数据访问

如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令

每一个操作数栈都会拥有一个明确的栈深度用于存储数值, 其所需的最大深度在编译器就定义好了,保存在方法的code属性中,为max_stack的值.

  • 动态链接(或运行时常量池的方法引用)

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用;

包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking);

比如: invokedynamic指令
在Java源文件被编译到字节码文件时, 所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在class文件的常量池里.

比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用.

PS: 几个概念
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关

  1. 静态链接和动态链接

静态链接
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变。
将调用方法的符号引用转换为直接引用的过程称为静态链接。

动态链接
被调用的目标方法在编译期无法被确定下来,只能够在程序运行期将方法的符号引用转换为直接引用,这种引用转换的过程具备动态性,称为动态链接。

  1. 早期绑定和晚期绑定
    绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

早期绑定
被调用的目标方法在编译期可知,且运行保持不变。

晚期绑定
被调用方法在编译期无法被确定下来,只能够在程序运行期根据实际类型绑定相关的方法。

  1. 虚方法和非虚方法
    非虚方法
    如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法;
    静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法;

虚方法
不是非虚方法的方法,都是虚方法;

  • 方法返回地址

存放调用该方法的PC寄存器的值
一个方法的结束, 有两种方式:

  1. 正常执行完成;
  2. 出现未处理的异常, 非正常退出;

无论通过哪种方式退出, 在方法退出后都返回该方法被调用的位置;

方法正常退出时, 调用pc计数器的值作为返回地址, 即调用该方法的指令的下一条指令的地址;

如果异常退出, 返回地址是通过异常表来确定, 栈帧中一般不会保存这部分信息.

正常完成出口和异常完成出口的区别在于:
通过异常完成出口退出的不会给他上一层调用者产生任何返回值。

  • 附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中.

例如与调试相关的信息, 这部分信息完全取决于具体的虚拟机实现.
实际开发中, 一般会把动态连接、方法返回地址与其他附加信息全部归为一类,成为栈帧信息.

本地方法栈(线程私有)

本地方法栈与虚拟机栈的区别是:
虚拟机栈执行的是 Java 方法, 本地方法栈执行的是本地方法(Native Method),其他基本上一致;

在 HotSpot 中直接把本地方法栈和虚拟机栈合二为一, 这里暂时不做过多叙述.

堆(线程共有)

堆内存和元数据区都被所有线程共享, 在虚拟机启动时创建.

Java 堆是内存空间占据的最大一块区域了, 用来存放对象实例及数组,也就是说我们 new 出来的对象都存放在这里。

这里也是垃圾回收器的主要活动营地了, 于是它就有了一个别名叫做 GC 堆, 并且单个 JVM 进程有且仅有一个 Java 堆.

现代JVM 采用分代收集算法, 因此Java堆从GC的角度还可以细分为: 新生代(Eden 区和From Survivor 区和 To Survivor 区)和老年代.

image.png

新生代(占1/3的堆空间,通常使用MinorGC)

新生代几乎是所有 Java 对象出生的地方, 用来存放新生的对象.

由于频繁创建对象, 所以会频繁触发 MinorGC 进行垃圾回收.

新生代又细分为三个区

  • Eden
  • From Servivor (也叫S0)
  • To Servivor (也叫S1)
Eden : S0 : S1  占比为 :   8:1:1  (可以通过参数 –XX:SurvivorRatio 来设定)

JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务, 所以无论什么时候,总是有一块 Survivor 区域是空闲着的, 谁空闲谁就是To区,To区不参与垃圾回收;
因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间.

MinorGC 的过程(复制->清空->互换)

一般情况下, 新对象都会在新生代 ( Eden 和 一个 Survivor 区域, 假设是 From 区域 ) 出生;
对于大对象 ( 即: 需要分配一块较大的连续内存空间 ) , 新生代放不下时, 则直接进入到老年代;

一次完整的MinorGC 的过程如下:

  • 复制: Eden、From复制到 To,年龄+1
    在初始阶段, 新创建的对象被分配到Eden区, 此时From 和 To 都为空;
    当Eden满的时候会触发第一次GC, 此时会把还活着的对象复制到 From;
    当Eden再次触发GC的时候会扫描Eden和From, 对这两个区域进行垃圾回收;
    经过这次回收后, 还存活的对象会复制到 To

  • 清空: 清空Eden、From
    上述操作完成后, 清空 Eden 和 From 中的对象

  • 互换: To 和 From 互换(谁空谁是To区)
    最后,To 和 From 互换,原 To 成为下一次 GC 时的 From区;

PS: 对象会在From和To区域中复制来复制去, 如此交换15次(JVM默认为15,最大也是15,因为只留了4个字节; 可以通过参数 -XX:MaxTenuringThreshold 来设定),
最终如果对象还是存活, 就存入老年代.

老年代(占2/3的堆空间,通常使用MajorGC)

老年代主要存放应用程序中生命周期长的内存对象, 这些对象比较稳定, 所以 MajorGC 不会频繁执行.

在进行 MajorGC 前一般都先进行 了一次 MinorGC, 使得有新生代的对象晋身入老年代, 导致老年代空间不够用时才触发.

当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间.

MajorGC(标记->清除)

MajorGC 采用标记清除算法: 首先扫描一次所有老年代,标记出存活的对象,然后回收没 有标记的对象.

MajorGC 的耗时比较长,因为要扫描再回收.

MajorGC 会产生内存碎片,为了减 少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配.

当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常

方法区(线程共有)

方法区是线程共享的, 主要存储类信息、常量池、静态变量、JIT编译后的代码等数据, 理论上来说方法区是堆的逻辑组成部分;

前面简单提过, 方法区只是JVM规范定义的一个数据区, 不同的JVM对其实现方式不同, 而我们常用的HotSpot对方法区的实现, 随着JDK版本的升级, 也是经历了多次调整.

JDK1.7到1.8的内存结构对比

  • JDK1.6及之前(永久代): 方法区存放类信息、字符串常量池、静态变量、即时编译器编译后的代码等数据

  • JDK1.7及以后(永久代): 将静态变量、字符串常量池从方法区中移了出来,放在了JVM堆中

  • JDK1.8及以后(元空间): 类的元信息(类信息、字段、方法、常量等)被存储在元空间中; 常量池和静态变量被放在了JVM堆中

为什么要用元空间来替代永久代呢?

简介: 永久代主要存放 Class 和 Meta(元数据)的信息, Class 在被加载的时候被放入永久区域,它和和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理;
所以这 也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常.

  • 为永久代设置空间大小很难确定
    一个应用动态加载的类的大小是很难确定的,如果永久代设置的过小,会频繁触发FullGC,并且可能会出现OOM.
    而元空间并不在虚拟机中,而是使用本地内存, 因此,理论上系统可以使用的内存有多大,元空间就有多大, 所以出现永久代的OOM的概率很小

  • 对永久代进行调优十分困难
    永久代的调优是很困难的, 虽然可以设置永久代的大小,但是很难确定一个合适的大小, 因为其中的影响因素很多, 比如类数量的多少、常量数量的多少等;
    将元数据从永久代剥离出来, 不仅实现了对元空间的无缝管理, 还可以简化Full GC以及对以后的并发隔离类元数据等方面进行优化

请关注我的订阅号

订阅号.png

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

码哥说

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值