写点什么

JVM 运行时数据区,你真得好好学一学

用户头像
Simon郎
关注
发布于: 2020 年 11 月 20 日
JVM运行时数据区,你真得好好学一学


  • 1、运行时数据区概要

  • 2、程序计数器 2.1 线程私有 2.2 执行 Java 方法时,计数器有值 2.3 执行 native 方法时,计数器为空

  • 3、Java 虚拟机栈

  • 4、本地方法栈

  • 5、Java 堆 5.1 堆内存的布局结构 5.2 对象的创建

  • 6 方法区

  • 7、总结


JVM 的运行时数据区

对于学过C++的开发者而言,他们对内存的分配与回收肯定不陌生,因为他们要对每一个对象负责(从创建到结束)。但是对于 Java 程序员来说,就不需要考虑那么多,因为虚拟机的内存管理机制可以帮助我们自动的管理内存,我们不再需要为每一个 new 操作去写配对的 delete/free 代码 。

既然虚拟机都这么方便了,那么我们为什么还要学内存管理呢,这不是自讨苦吃么,事实上,虚拟机的自动内存管理确实能帮助我们减少内存泄漏和内存溢出的情况;但是也正因为我们把内存的控制权交给了虚拟机,一旦出现内存泄漏和内存溢出的问题,如果我们不了解虚拟机是怎么使用的内存的,那么我们该怎么解决呢?所以,我们非常有必要学习内存管理,学习它不是为了自己控制管理内存,而是在出现问题的能够有效的定位并予以解决。

所以,让我们一起来学习吧。

1、运行时数据区概要

Java 虚拟机将 Java 程序执行的区域称为运行时数据区,根据各自功能不同将运行时数据区划分为若干个不同的区域,具体分为两大块,线程共享部分和线程私有部分。线程共享部分可以分为方法区(jdk1.8 后这块区域被称为元空间);线程私有部分可以分为虚拟机栈本地方法栈程序计数器

1.1 运行时数据区

上述的这些区域都有各自的用途,下面让我们一个个来学习学习他。

2、程序计数器

程序计数器是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支跳转循环异常处理线程恢复等基础功能都需要依赖这个计数器来完成。

这是《深入理解 Java 虚拟机》书籍对程序计数器的介绍,事实上,在此基础应该补充上,程序计数器是线程私有,在执行 Java 方法时有值,但是在执行 native 方法时,程序计数器值为空。

有没有看懵,懵了也没关系,下面我们抽出程序计数器的特点,并介绍每个特点的来源及作用。

2.1 线程私有

首先,为什么线程私有呢,我们都了解 Java 虚拟机的多线程是通过轮流切换分配处理器的执行时间的方式来实现的,也就是说,在同一时刻一个处理器内核只会执行一条线程,处理器切换时并不会记录上一个线程执行到那个位置,所以为了线程切换后依然能够恢复到原位,每条线程都需要有各自的独立的程序计数器,计数器之间互不影响,独立存储。

2.2 执行 Java 方法时,计数器有值

这个特点列出来好像有点白痴,我们在上面都已说了它是行号计数器,那肯定是有值啊,那么我们还要单独列出来呢,我们单独列出来一方面是为了与执行 native 方法比较,另一发面我是想解释下线程执行字节码时,这个行号指示器到底是个啥?好吧,通俗来讲,JAVA 代码经 javac 编译后的得字节码在未经过 JIT(实时编译器)编译前,其执行方式是通过“字节码解释器”进行解释执行的,其工作原理就是为解释器读取装载入内存的字节码,按照顺序读取字节码指令,这个过程就是行号指示器在不断变化的过程。当读取一条指令后,就讲该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等流程。

2.3 执行 native 方法时,计数器为空

当执行native本地方法时,程序计数器是空的,这是因为 native 方法是 java 调用本地的C/C++库,可以近似的认为 native 方法相当于 C/C++暴露给 java 的一个接口,java 通过调用这个接口从而调用到 C/C++方法。由于该方法是通过 C/C++而不是 java 进行实现。那么自然无法产生相应的字节码,并且 C/C++执行时的内存分配是由自己语言决定的,而不是由 JVM 决定的。

2.1 方法执行流程

NOTE:学到这里,相信你对程序计数器已经了解的的差不多了,但是你可能还存在这样的疑惑,程序计数器占用的内存那么小,会不会抛出内存溢出错误OutOfMemorryError,别担心,不会出现错误的,既然程序计数器存储的是字节码文件的行号,那么程序字节码执行的范围肯定是已知的,在虚拟机将字节码文件加载进内存时就已经分配一个绝对不可能的溢出的内存,为啥会提前知道,因为字节码文件包含的有相关信息,如果想要更加具体的了解,可以看看我的上一篇文章,认识Class文件结构

3、Java 虚拟机栈

好了,学习完程序计数器,我们接下来学习线程私有的另一部分内容:Java虚拟机栈

虚拟机栈描述的是 Java 方法执行的内存模型,每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法被调用直至执行完毕的时候,就对应着一个栈帧从入栈到出栈的过程。

看到上面这么长的定义可能有点懵逼,栈帧是个啥,里面存的都是些啥玩意,我学它干啥,搞得挺痛苦的。莫慌,我们一个个解释,看完我的解释后绝对让你喊出“真香”。

首先,既然虚拟机栈描述的是 Java 方法的内存模型,那我们就认为他是存储 Java方法集合的内存,而栈帧就可以认为集合中的一个方法,方法间的调用就对用着栈帧的调用,当执行一个方法,就将该方法的栈帧压入栈顶,方法执行完就退出栈,也即从方法集合中去掉。

抽象?来一张图看看

3.1 虚拟机栈

虚拟机栈里存储的是一个个栈帧,栈帧里面包含啥啊?下面,我们下先看一张图来直观感受下

3.2 栈帧

局部变量表是一组变量值存储空间,用于存放方法参数方法内部定义的局部变量。在 Java 程序编译为 Class 文件时就在方法的 code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。局部变量可以存放基本数据类型(boolean、byte、char、short、int、float、long、double)和对象引用类型(reference)。

局部变量是以变量槽(Slot)为单位,每个槽的容量为32位,所以对于小于 32 位的类型占用一个变量槽,64 位长度的 long 和 double 类型的数据会占用两个变量槽。

3.3 变量槽

JVM 会为局部变量表中的每一个 slot 都分配一个访问索引,通过这个索引就可以成功的访问到局部变量表中的指定局部变量值。当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照声明顺序被复制到局部变量表中的每一个 slot 上。

Note:栈帧中的局部变量表中的槽位是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

4、本地方法栈

本地方法栈也是线程私有的部分,本地方法栈与虚拟机栈方法作用相似,其区别仅是虚拟机栈为 Java 方法服务,而本地方法栈则是为虚拟机使用到的本地方法服务。

5、Java 堆

Java堆是虚拟机的内存中的最大一块,它能被所有的线程共享,在虚拟机启动时创建,我们在 Java 代码编写的对象实例就存在这快内存区域。

堆到底是个什么样的结构呢?它由那几部份组成呢,每部分都各自有什么作用呢?

别慌,我们一个一个来。

5.1 堆内存的布局结构

我们都知道对象的存活是有周期的,如果一个对象没有被引用,那么就可以认为该对象可以被清除掉了,就是我们认为的垃圾。由于每个对象存活的时间不同,为了减少 GC 线程扫描垃圾时间及频率,我们可以将存活时间较长的对象单独放一个区域。因此,堆的布局也就确定下来了。总的来说,堆被划分成两部分:新生代和老年代。

5.1 堆内存结构

新生代和老年代比值为1:2,这个比例并不是唯一的,我们可以可以通过参数 –XX:NewRatio按照具体的场景来指定。

如果再细粒度的划分,新生代又可以分为 Eden区Survivor区,而Survivor区又可以分为FromSurvivorToSurvivor,默认比值为8:1:1

5.2 新生代内存结构

这时问题又来了,为什么要将 Survivor 分为两块相等大小的空间啊?好问题,我先说答案,这两分为两部分主要是为了解决内存碎片化的问题,如果内存碎片化严重,也就是两个对象占用不连续的内存,已有的连续内存不够新对象存放,就会触发 GC。不懂 GC 的先暂时这样理解,在下一篇文章垃圾回收算法时,我会重点讲解。

知道堆的内存结构布局后,我们聊一聊对象是如何在堆中创建的。

5.2 对象的创建

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,先执行相应的类加载过程,接下来为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从堆中划分出来。划分方式按照堆内存是否规整分为两种。

  • 堆内存规整

可以采用指针碰撞方式解决,即所有被使用过的内存都放到一边,空闲的内存被放到另一边,中间放着一个指针作为分界点的指示器,那所分配的内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离。

  • 堆内存不规整

可以采用空闲列表的方式解决,空闲和使用的内存相互交错,JVM 必须维护一个列表,记录哪些内存块是可用的,分配时候找到一块足够大的分配给对象实例。

  • 安全性

我们还有一个问题值得考虑的是,如果在并发情况下,对象的创建是否安全呢,会不会出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存。废话,肯定会出现这样的情况,可以有两种办法解决:①可以对分配内存空间的动作进行同步处理,这实际上是虚拟机采用CAS加上重试机制保证更新操作的原子性。②把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配心得 TLAB 时,才需要同步锁定。

6 方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,在 jdk1.8 后,这部分内存被放置在元空间中,是一种逻辑内存部分。它是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息常量静态变量、即时编译器编译后的代码缓存数据,这些信息是由类加载时从类文件中提取出来的。

6.1 动态类加载

除此之外,方法区还具备以下特点:

  • 方法区存放的位置及大小

因为 jvm 在运行应用时要大量使用存储在方法区中的类型信息,所以在类型信息的表示上,设计者除了要尽可能提高应用的运行效率外,还要考虑空间问题。根据不同的需求,jvm 的实现者可以在时间和空间上追求一种平衡,具体体现在方法区的大小不必是固定的,根据应用的需要动态调整。同样方法区也不必是连续的。方法区可以在堆(甚至是虚拟机自己的堆)中分配。jvm 可以允许用户和程序指定方法区的初始大小,最小和最大尺寸。

  • 线程安全性

因为方法区是被所有线程共享的,所以必须考虑数据的线程安全。假如两个线程都在试图找 lava 的类,在 lava 类还没有被加载的情况下,只应该有一个线程去加载,而另一个线程等待。

7、总结

这篇文章详细讲解了 Java 虚拟机内存的各部分区域,这部分内容非常重要,接下来的文章:类加载机制、内存分配和垃圾回收算法都是以这篇为基础的。

好了,今天的文章就到这里了,我是Simon郎,一个想要每天博学一点点的少年郎,如果这篇文章对你有帮助,欢迎在看点赞转发哦!


参考文献

[1]https://www.cnblogs.com/yanl55555/p/12616356.html

[2]深入理解 JVM 虚拟机.周志华


发布于: 2020 年 11 月 20 日阅读数: 58
用户头像

Simon郎

关注

自学;制学;博学 2019.10.09 加入

公众号:小郎码知答

评论

发布
暂无评论
JVM运行时数据区,你真得好好学一学