JVM(二)-运行时数据区

概述:

  对于从事C、C++开发的程序员来说,在内存管理领域,他们既是拥有最高权力的“皇帝”,又是从事最基础工作的劳动人民——既拥有每个对象的“所有权”,

又担负着每一个对象从开始到终结的维护职责。

  对于java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为没一个new操作去配对的free/delete(C、C++语言对对象的删除和内存释放操作),

不容易出现内存泄漏和内存溢出问题,看起来由虚拟机管理内存一切看起来很美好。不过,也正是java把控制内存的权力交给了java虚拟机,一旦出现内存泄漏

和内存溢出方面的问题,如果不了解虚拟机是怎么使用内存的,那排查错误、修正问题将会成为一项异常艰难的工作。

运行时数据区:

  java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有些区域会随着

虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。如下图所示:

我们知道JVM也属于一种特殊的操作系统,那这些数据区域跟我们最常用的windows哪些部分相对应呢。我们可以吧windows的CPU+缓存+主内存和JVM的执行引擎+操

作数栈+(栈、堆)对应起来,这样更加利于我们去理解JVM。

虚拟机栈:

  从上图可见,java虚拟机栈是线程私有的,它的生命周期和线程相同。虚拟机栈描述的是java方法执行的线程内存模型:每个方法被执行的时候,java虚拟机都会

同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、返回地址等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到

出栈的过程。我们来通过一段非常简短的代码来演示虚拟机栈的作用:

/**
 * @ClassName StackTest
 * @description:
 * @author:liuyi
 * @Date:2020/11/23 23:45
 */
public class StackTest {

    public static void main(String[] args) {
        A();
    }

    static void A(){
        B();
    }

    static void B(){
        C();
    }

    static void C(){

    }
}

  当我们运行main方法,虚拟机会开启一个线程,同时为当前线程划分一块内存区域作为当前线程的虚拟机栈。同时在执行每个方法的时候都会打包成一个栈帧。

比如 main 开始运行,打包一个栈帧送入到虚拟机栈。C 方法运行完了,C 方法出栈,接着 B 方法运行完了,B 方法出栈、接着 A 方法运行完了,A 方法出栈,

最后 main 方法运行完了,main 方法这个栈帧就出栈了。这个就是 Java 方法运行对虚拟机栈的一个影响。虚拟机栈就是用来存储线程运行方法中的数据的。而

每一个方法对应一个栈帧。入栈过程如下图所示:

 上图描述了整个main方法调用的入栈和出栈的过程,需要注意的是栈帧出栈之后就没了,栈帧没得GC的说法。

栈帧详解:

  栈帧大体都包含四个区域:(局部变量表、操作数栈、动态连接、返回地址)

  • 局部变量表:顾名思义就是局部变量的表,用于存放我们的局部变量的(方法中的变量)。首先它是一个 32 位的长度,主要存放我们的 Java 的八大基础数据
类型,一般 32 位就可以存放下,如果是 64 位的就使用高低位占用两个也可以存放下,如果是局部的一些对象,比如我们的 Object 对象,我们只需要存放它的一个引用地址即可。
  • 操作数栈:存放 java 方法执行的操作数的,它就是一个栈,先进后出的栈结构,操作数栈,就是用来操作的,操作的的元素可以是任意的 java 数据类型,所
以我们知道一个方法刚刚开始的时候,这个方法的操作数栈就是空的。操作数栈本质上是 JVM 执行引擎的一个工作区,也就是方法在执行,才会对操作数栈进行操作,如果代码不不执行,操作数栈其实就是空的。
  • 动态连接:Java 语言特性多态(后续章节细讲,需要结合 class 与执行引擎一起来讲)。
  • 方法出口:正常返回(调用程序计数器中的地址作为返回)、异常的话(通过异常处理器表<非栈帧中的>来确定)。

我们来通过分析一个简单的方法来理解栈帧中各个区域是如何运作的,代码如下:

/**
* @ClassName User
* @description:
* @author:liuyi
* @Date:2020/11/25 20:51
*/
public class User {
public static int work(){
int a = 2;
int b = 3;
int c = a*b;
return c;
}

public static void main(String[] args) {
System.out.println(work());
}
}

 当该程序运行的时候,JVM会为其分配虚拟机栈,并生成对应的栈帧,如下图所示:

我们通过反汇编命令查看work方法的字节码如下:

我们看到work方法一共由10条字节码组成,我们来逐步分析。

打开 https://cloud.tencent.com/developer/article/1333540查看字节码指令

现来看iconst_2对应的含义,如图

 所以第1个字节码是将一个值为2的数字加载到操作数栈。再来看 istore_0的含义,如图

 

  所以第2个字节码的含义就是将第一步中放入到操作数栈的数字放到局部变量表中,位置为0。所以前面两个字节码对应的java代码就是int a = 2;那么显而易见3和4两个字节码对应的

就是int b = 3;到这里,大家心里肯定会有疑问,为什么不直接将值放到局部变量表呢?我们接着分析,你就明白了。

  继续来看第5和第6两个字节码:iload_0和iload_1,它们的含义是将局部变量表中位置0和1的两个数加载到操作数栈中,接着我们来看关键的第7个字节码:imul,它代表的意思

是相乘,就是将操作数栈中的数字进行乘法运算,我们知道相乘是需要运算的,所以此时要交给执行引擎运算,运算完成之后再将运算的结果返回到操作数栈。所以操作数栈的作用

就是为jvm高速的计算提供缓冲区。

  接着来看第8个字节码:istore_2,它的含义就是将计算的结果放入局部变量表,到这里int c = a*b;就执行完了。然后再来看第9和第10个字节码,它们的含义是将局部变量表的值再

压入操作数栈,最后返回。至此,整个方法执行结束,以上就是栈帧中各个区域在方法执行中的运作流程。

 虚拟机栈大小的设置:

   虚拟机栈的大小缺省为 1M,可用参数 –Xss 调整大小,例如-Xss256k。

参数官方文档(JDK1.8):https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html。

 

 我们可以看到linux的建议配置为1M,至于windows为啥没有,博主大胆猜想可能跟微软和Oracel两家公司竞争有关吧,毕竟微软开发.net就是和java竞争的。

虚拟机栈相关的程序异常:

  • StackOverflowError异常:如果线程请求的栈深入大于虚拟机所允许的深度,将抛出StackOverflowError异常,通常是由无线递归导致的,如下面的代码

 

  •  OutOfMemoryError:如果java虚拟机的容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。这种情况基本很少出现,也很难模拟,这里就不演示了。

程序计数器:

  与虚拟机栈一样,程序计数器也是线程私有的。程序计数器是一块很小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器,就如上面反汇编User.class看到的一样。每一个字节码都有自己的序号:

 

   如上图所示,虽然这些序号是由顺序的,但是并不一定是依次递增,如果某给字节码占用的空间很大,那么它的序号相较于前一个序号就差距更大。

在java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的执行器,分支、

循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。

  它还有另外一个作用,我们知道在java中可以开启成百上千个线程,但是我们一般的电脑CPU也就8个左右。java虚拟机的多线程是通过线程轮流切换、

分配处理器执行时间方式来实现的,那么切换后虚拟机是怎么知道以前运行的位置,继续运行的呢?这个时候,程序计数器就起到了决定性的作用,因为

程序计数器是线程独有的,所以不会相互影响,当切回到当前线程,根据程序计数器记录的序号,继续执行对应的字节码即可。

  在JVM中,只有执行java方法的时候,程序计数器才会记录正在执行的虚拟机字节码指令的地址,如果正在执行的是本地(Native)方法,这个计数器

则应为空(Undefined)。但是这里会产生一个疑问,如果刚好在执行Native方法的时候线程切换了,那切回来之后该怎么找到对应的位置呢?这里,我猜测

JVM可能规定了 在执行Native本地方法的时候,禁止切换当前线程(如不正确,请指正)。

本地方法栈:

  本地方法栈与虚拟机栈的作用非常相似,其区别只是虚拟机栈为java方法服务,而本地方法栈专门为Native本地方法服务。需要注意的是,HotSpot直接把

本地方法栈和虚拟机栈合并了。

java堆:

  对于java应用程序来说,java堆是虚拟机管理的内存最大的一块。java堆是被所以线程共享的一块内存区域,在虚拟机启动的时候创建。此内存区域唯一的

目的就是存放对象实例,java世界里几乎所有的对象实例都在这里分配。java堆是垃圾收集器管理的内存区域,也常被称为GC堆。从回收垃圾的角度来看,由于

现代垃圾收集器大部分都是基于分代收集理论来设计的,所以java堆中经常会出现“新生代”、“老年代”、“永久代”、“Eden空间”、“From Survivor空间”、“To Survivor空间”

等,这些概念后续都会详细讲解。

  java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的java虚拟机都是按照可扩展来实现的(通过参数  -Xmx和-Xms来设定)。如果java堆中没有

可供分配实例的内存,并且堆也无法再扩展时,java虚拟机将会抛出OutOfMemeroyError异常。

  我们知道,java程序在运行过程中,需要大量的创建和回收实例对象。如何如何更快、更合理的进行内存分配和回收就成了堆最重要的任务。这里我们只是对堆有了

初步的认识,后续我们将对堆的垃圾回收器以及内存分配策略进行详细的讲解。

方法区:

  方法区与java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态常量、即时编译器编译后的代码缓存等数据。说到方法区,不得

不提一下“永久代”的概念。在JDK8以前,许多java程序员都把方法区称为“永久代”,或将两者混为一谈。但是实际上两者并不是等价的,仅仅是HotSpot虚拟机设计团队选择

把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已。这样是得HotSpot得垃圾回收器可以像管理java堆一样管理方法区的内存,省去专门为方法区编写

内存管理代码的工作。但是其他虚拟机实现,是不存在永久代的概念的。但现在回头来看,当年使用永久代来来实现方法区的决定并不是一个好主意,这种设计导致java应用

更容易出现内存泄漏。在JDK7,HotSpot已经将字符串常量池、静态变量等移除,而到1.8,则完全废弃了永久代概念。改用在本地内存中实现元空间来实现。

  方法区中存放的数据并不是如永久代的含义一样永久存在,但是这个区域存放的数据确实相对稳定,所以这个区域很少会出现垃圾回收行为。但是当方法区无法满足内存

分配需求时,依然会抛出OutOfMemeroyError异常。另外这里必须提一下,在方法区中类的加载机制是一个非常重要的点,后续会详细讲解。

运行时常量池:

  运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,

这部分内容将在类加载后放到方法区的运行时常量中。除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。所以也可以认为

运行时常量池的作用就是把符号引用转换为直接引用。

  运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,java语言并不要求常量池一定只有编译期才能产生,运行期间也可以将新的常量放入池中,比如String

类的intern()方法。

  运行时常量区既然属于方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemeroyError异常。

直接内存:

  直接内存并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。但是有的时候,我们需要绕过虚拟机,去直接分配内存进行使用。这样能在一些场景

中显著提高性能,因为避免了java堆和Native堆中来回复制数据。在java中我们可以使用DirectByteBuffer对象之间操作操作系统上的内存。

  显然,本机内存的分配不会收到java堆大小的限制,但是既然是内存,则肯定会受到本机总内存的限制,当没有可以分配的之间内存时,依然会出现OutOfMemeroyError异常。

总结:

  本篇文章介绍了JVM的运行时数据区域,运行时数据区域主要分为线程共享区和线程私有区。线程私有区主要介绍了虚拟机栈的各个组成部分以及java方法是怎么通过虚拟机

栈来实现执行的,接着介绍了程序计数器的作用最后简述了本地方法栈。接着我们介绍了线程共享数据区,主要包括堆、方法区、运行时常量池以及直接内存等内容。

posted @ 2020-11-25 22:51  负重前行的小牛  阅读(316)  评论(2编辑  收藏  举报