1: JVM内存区域

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,本质上就是一个程序。Java虚拟机有自己完善的硬件架构,如处理器、堆栈等,还具有相应的指令系统。但是它没有寄存器,所以指令集是使用Java栈来存储中间数据的。 -- 百度百科-JVM

线程(英语:thread)是独立调度和分派的基本单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。--百度百科-线程

1 JVM所涉及的内存区域分类

1.1 私有区域

此类区域每个线程都有独属于自己的,线程之间相互隔离,不相互影响。此类区域生命周期与线程一致,随着线程的产生而产生,随着线程的销毁而回收。此类区域包含:程序计数器(PC),虚拟机栈(VM Stack),本地方法栈(Native Method Stack)。

1.2 线程共享区域

此类区域属于JVM层次的,JVM中的所有线程共享这类区域。此类区域生命周期与JVM一致,随着JVM的启动而产生,随着JVM的关闭而销毁。此类区域包含:JAVA堆,方法区

1.3 直接内存区域

此类区域不属于JVM,它的生命周期与JVM无关。也不受JVM垃圾回收(GC)所管理。可以理解为JVM所在的计算机的直接内存。JAVA有一套方法来操作这部分内存。例如一个txt文件,你可以通过JAVA来操作这个文件。但是它实际是直接存储在计算机内存中,它所在的内存区域不属于JVM,这块区域也不会因为JVM的关闭而被销毁。

2 JVM所管理的内存区域

JVM内存模型

JVM内存模型,图片来源https://blog.csdn.net/hylexus/article/details/53564865

JVM内存模型,图片来源https://blog.csdn.net/hylexus/article/details/53564865

2.1 程序计数器(线程私有)

一块较小的内存区域。如果是执行的JAVA方法,存放的是当前线程正在执行的字节码指令地址。线程因为各种原因挂起,然后在恢复时,程序计数器中存放的地址可以让CPU需要知道这个线程在挂起之前已经执行到哪一步了,以便继续执行。

如果执行的是一个Native方法,则存放的内容为空。但是存放为空,如果线程被挂起恢复时又如何确定执行到哪一步呢?

这里的“pc寄存器”是在抽象的JVM层面上的概念——当执行Java方法时,这个抽象的“pc寄存器”存的是Java字节码的地址。实现上可能有两种形式,一种是相对该方法字节码开始处的偏移量,叫做bytecode index,简称bci;另一种是该Java字节码指令在内存里的地址,叫做bytecode pointer,简称bcp。
对native方法而言,它的方法体并不是由Java字节码构成的,自然无法应用上述的“Java字节码地址”的概念。所以JVM规范规定,如果当前执行的方法是native的,那么pc寄存器的值未定义——是什么值都可以。
上面是JVM规范所定义的抽象概念,那么实际实现呢?
Java线程总是需要以某种形式映射到OS线程上。映射模型可以是1:1(原生线程模型)、n:1(绿色线程 /用户态线程模型)、m:n(混合模型)。 以HotSpotVM的实现为例,它目前在大多数平台上都使用1:1模型,也就是每个Java线程都直接映射到一个OS线程上执行。此时,native方法就由原生平台直接执行,并不需要理会抽象的JVM层面上的“pc寄存器”概念——原生的CPU上真正的PC寄存器是怎样就是怎样。就像一个用C或C++写的多线程程序,它在线程切换的时候是怎样的,Java的native方法也就是怎样的。

作者:RednaxelaFX 链接:www.zhihu.com/question/40…
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

另外,程序计数器存的就是一个地址,其内存大小是可预见的。因此这个内存区域是唯一一个在虚拟机中没有规定任何OutOfMemoryError 情况的区域。

2.2 虚拟机栈(线程私有)

虚拟机栈的栈元素为栈帧(Stack Frame)。方法的调用和结束就对应着栈帧的入栈和出栈。栈帧可以理解为一个方法的运行空间。栈帧的内部分为局部变量表,操作数栈,动态链接,方法返回地址,帧数据区。

栈帧结构,图片来源https://www.cnblogs.com/lsgxeva/p/10231221.html

栈帧结构,图片来源www.cnblogs.com/lsgxeva/p/1…

2.2.1 局部变量表(Local Variable Table)

  • 局部变量表保存着方法的入参以及在方法内部定义的局部变量。

  • 它的容量最大值在编译时就已经确定了,运行时不会改变,其值存放在Class文件中,方法的Code属性的max_locals数据项中。

  • 局部变量表的空间单元(容量最小单位)是变量槽(Variable Slot)。每个Slot最大可以存放32位的数据。因此,基础数据类型中的byte(8位),short(16位),int(32位),float(32位),char(16位),boolean 以及对象的引用reference都占用一个Slot(reference只是一个引用,而非实际的对象),而64位的long以及double则占用两个Slot,对于64位的数据类型,JVM要求必须对其占用的Slot连续访问,不能单独访问其中的一个Slot,如果有对64位数据类型占用的Slot进行单独访问的字节码,则JVM会在类加载的校验阶段就抛出异常。

  • 局部变量表通过索引来确定数据位置,索引范围是0-变量槽的数量。而对于0号变量槽,如果当前栈帧所对应的方法为实例方法(未被Static修饰的方法),而不是成员方法(被Static修饰的方法),那么0号变量槽存的就是该方法所属对象实例的引用。使用this关键字时就会指向这个变量槽。

  • 类变量的赋值会有两个阶段,首先是准备阶段,JVM会将类变量赋值初始值,然后是初始化阶段,类变量被赋值为目标初始化值。而局部变量则不会有准备阶段,这也是为什么类变量不初始化即可使用而局部变量只有初始化才能使用的原因。

  • 局部变量表变量所占用空间是可以复用的,也就是说当指令执行到一个变量的作用域之外,又有新的变量被定义的话那么这个变量所在的空间会被新的变量所覆盖。比如:

    do {
        int i = 1;
    } while (false);
    int j = 0;// 执行到这一步时,已经超出了i的作业域,此时j就会复用i的空间
    复制代码
  • 虽然在规范上来说,栈帧和栈帧是相互独立的,但是大部分JVM都会针对局部变量表做复用优化,也就是说当前栈帧的操作数栈的一部分与下一栈帧的局部变量表的一部分会重合在一起,这样方法间的调用进行参数传递时就可以复用共享区域中的传递参数,减少了参数复制。如下图:方法1对应的栈帧1先入栈,此时调用方法2,那么方法2对应的栈帧2入栈,此时如果方法1传递给方法2的参数中包含方法1操作数栈中的变量,此时这个变量就无需复制一份到栈帧2的局部变量表,重叠区域既是栈帧1的操作数栈又是栈帧2的局部变量表,达到复用目的。因为是调用关系,栈帧2执行期间栈帧1的内容都是不变的,所以也不用担心数据会变动。

    在这里插入图片描述

  • 值得注意的是局部变量表中的数据并不能直接使用,变量的运算需要使用操作数栈。

2.2.2 操作数栈(Operand Stack)

  • 操作数栈同局部变量表一样,操作数栈的最大深度也是在编译期间就已经确定了,运行时不会改变。其值在编译时被写入到 Class 文件格式属性表的 Code 属性的 max_stacks 数据项中。
  • 操作数栈和局部变量表一样,每一个元素可以最大容纳32位的数据,对于小于32位的数据类型,占用一个容量。64位的数据类型占用两个容量。在方法开始执行时,操作数栈时空的。
  • 变量运算是通过操作数栈来实现的。如果要对局部变量表中的数据进行运算,需要先将其压入操作数栈。如int a = 1;int b = 2; int c = a + b;对于此过程a和b会在局部变量表i和i+1处,然后将a取出压入操作数栈,将b取出压入操作数栈,然后将栈顶的两个元素出栈,执行加法运算,将结果入栈并放入局部变量表i+2处。
  • 操作数栈也可以用来进行参数传递,就是通过上面局部变量表部分描述的的栈帧重叠来实现的。

2.2.3 动态连接(Dynamic Linking)

  • 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。简单的说就是将栈帧与常量池中的方法管理起来,标记这个栈帧是属于哪一个方法的。
  • 动态连接发生在栈帧完全入栈之前,也在局部变量表等形成之前。
  • 在调用一个方法时,需要找到这个方法的直接引用,即这个方法在方法区的地址。形成对该方法的动态连接。这代表着找到了该方法的入口,也就找到了对应的字节码指令,接下来补充栈帧其他部分形成该方法的的栈帧。字节码指令指行完后,对应方法的栈帧出栈,根据返回地址,返回到调用方继续执行。
    注释:关于符号引用与直接引用:符号引用是编译时,因为类还未实例化,也就是还没有分配内存,所以引用类不
    知道被引用的类的实际地址是什么,但是因为进行了引用,就先用一个符号来标记这个引用。举例来说,有一个教
    室类Classroom和一个学生类Student,一个教师类Teacher。然后Student和Teacher都继承了People.并重写了
    People类的talk方法。编译时期只是表明教室里面有学生和老师。但是学生是谁呢,老师是谁呢,并不知道,因
    为还没进行实例化,也就是说学生和老师还不存在,所以我要写两个标签(符号引用),表明课桌这里引用的是学
    生,讲台上面引用的是老师。然后Classroom里面的conversation方法分别调用了talk方法。这里要注意,这时
    符号引用指向的并不是Studen和Teacher的talk而是指向了People的talk.
    而直接引用则是指向实际的地址。即对象被实例化后的内存地址。举例来说就是教室实际存在了,也确定了教室里
    面的学生是张三,所以课桌这里就直接指向了张三这个人,讲台这里就直接指向张老师这个人,而conversation
    方法的talk方法也指向张三和张老师的talk方法。
    复制代码

2.2.4 返回地址(Return Address)

  • 当一个方法被执行后,有两种方式退出这个方法。一种是方法执行完毕正常退出,有无返回值是由方法定义。这种退出方式被称为正常完成出口(Normal Method Invocation Completion)。一种抛出异常到上级方法,这种方式一定是没有返回值的,此种退出方式被称为异常完成出口(Abrupt Method Invocation Completion)。
  • 无论方法一哪种方式退出,都需要知道要退出到哪里,这样程序才能够继续执行。方法正常退出时,方法调用的地址就作为返回地址,栈帧会记录这个值。而异常返回时,程序执行将会被交给异常处理器,返回地址也由异常处理器确定。此时栈帧中一般不会保存这部分信息。
  • 方法的退出对应着栈帧的出栈,可能执行的操作有恢复调用方的局部变量表和操作数栈,将方法的返回值压入操作数栈,如果返回值被赋值给一个变量的话也会入局部变量表,将程序计数器的值置为下一个要执行指令的地址。

2.2.5 附加信息

  • 栈帧的附加信息,这个取决于JVM的实现,虚拟机可以根据自己的设计存入一些额外的信息。

一般情况下会把动态连接,方法返回地址与其它附加信息归在一起称为栈帧信息。

对于虚拟机栈,线程请求的栈深度大于JVM所允许的深度时会报错StackOverflowError 。JVM允许动态扩展,但是无法申请到足够内存时报错OutOfMemoryError。

2.3 本地方法栈(线程私有)

本地方栈的作用和虚拟机栈的作用类似。区别仅仅是虚拟机栈执行的是Java方法,本地方法栈执行的则是Native方法。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。例如某个虚拟机是使用C连接模型实现的本地方法接口,那个他的本地方法栈就是C栈。有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。 当线程调Java方法时,JVM会创建一个栈帧并压入虚拟机栈。当线程调用一个本地方法时,JVM并不会向虚拟机栈中压入新栈,JVM只是简单的动态链接并直接调用指定的Native方法。如果本地方法要回调JVM中的JAVA方法,那么虚拟机会保存本地方法栈的状态,并进入Java虚拟机栈。 图片来源https://www.iteye.com/blog/denverj-1220969

图片来源https://www.iteye.com/blog/denverj-1220969;《深入理解Java虚拟机:JVM高级特性与最佳实践》 作者: 周志明

同样的,本地方法栈的栈深度大于JVM所允许的深度时会报错StackOverflowError 。JVM允许动态扩展,但是无法申请到足够内存时报错OutOfMemoryError。

2.4 JAVA堆(线程共享)

Java 堆(Java Heap)在虚拟机启动时被创建。是所有线程共享的内存区域。实例化的对象以及数组就存储在这个区域(jdk1.7之后字符串常量以及类的静态变量也移到了这个区域),因此这个区域是垃圾回收(GC)的主要场所。从GC的角度,如果是采用分代GC(当前大多数JVM都采用的这种GC算法),堆内存又可以分为新生代(Young)和老年代(old),新生代又可以分为Eden区,From Survovir,To Survovir. 栈中的实例引用如果引用的是堆中的实例的话,实质上话就是记录了该实例在堆中的地址。栈中的引用在超出其作用域时空间被回收,而堆数据即使超出其作用域依然不会被销毁,空间的回收只能等待GC来做。 JVM规范规定堆空间可以是物理上不连续的空间,只要逻辑上连续即可。其空间大小可以设置为固定大小,也可以设置为可扩展的。( -Xms设置初始堆大小, -Xmx设置最大堆大小)。 如果JVM允许动态扩展,但是堆无法申请到足够内存时报错OutOfMemoryError。

2.5 方法区(线程共享)

方法区(Method Area)与 Java 堆一样,是所有线程共享的内存区域。 方法区,永久代,元数据区实质上表示的一个意思,方法区是JVM规范的概念,而永久代是HotSpot JVM在JVM1.8之前对JVM方法区规范的实现。元数据区则是HotSpot JVM在1.8及之后对JVM方法区规范的实现。可以理解为方法区是一个接口,任何类型的虚拟机都要实现这个接口,它是一个概念上的规范。而永久代和元数据区则是 HotSpot JVM对于方法区这个规范概念在1.8前后的具体实现。 对于HotSpot JVM来说,要理解方法区,首先要看看其JDK版本,不同的版本有不同的是实现。

  • JDK1.7及其之前

方法区通过永久代(Permanent Generation)实现,方法区的大小可以通过参数-XX:PremSize设置初始大小,以及通过参数-XX:MaxPermSize设置所能运行的最大大小。这个时间的的永久代会受限于JVM本身的内存,也就是说永久代的大小不会超过JVM自己所分配到的内存大小。

  • JDK1.8及其之后

移除了永久代,通过元空间(Metaspace)来实现,因此控制永久代大小的参数-XX:PermSize以及-XX:MaxPermSize已经失去了其作用。而是使用-XX:MetaspaceSize以及-XX:MaxMetaspaceSize控制元空间大小。而且元空间已经不在JVM内存中,而是直接使用本地内存,这就意味着元空间的大小不在受限于JVM本身的内存大小了。

不同版本的HotSpot JVM除了方法区的实现上的不同,方法区所承载的功能也随着JDK的版本产生了变迁:

  • 1.6及其以前
    • Klass元数据信息
    • 每个类的运行常量池(字段,方法,类,接口等符号引用),编译后的代码
    • oop(Ordinary Object Pointer(普通对象指针)),其实就是Class实例.
    • 全局字符串常量池StringTable,其实就是HashTable
    • 符号引用(类型指针是SymbolKlass)
  • 1.7
    • Klass元数据信息
    • 每个类的运行常量池(字段,方法,类,接口等符号引用),编译后的代码
    • 静态字段由instanceKlass末尾移动到了java.lang.Class(oop)对象的末尾(位于Java Heap内)
    • oop与全局字符串常量池移到Java Heap内
    • 符号引用被移动到Native Heap内
  • 1.8及其以后
    • 移除永久代
    • Klass元数据信息
    • 每一个类的运行时常量池,编译后的代码移到了另一块与堆不相连的本地内存--元空间(MetaSpace)

Klass元数据信息:类的基本信息,如类型的全限定名,超类的全限定名,直接超接口的全限定名,类型标志(该类是类类型还是接口类型),类的访问描述符(public、private、default、abstract、final、static)等等
每个类的运行常量池:类中的字段,方法,类,接口等的符号引用。要注意这里放的是符号引用,而不是直接引用,即使是基础数据类型也是符号引用,这里是元数据区,是不会有一般对象的实例的。而符号引用也只是一个说明这个变量是什么的标记(符号具体见2.2.3动态链接的注释部分)。
oop(Ordinary Object Pointer(普通对象指针)):就是Class实例.通过this.getClass获得的Class对象就是它. 全局字符串常量池:对于字符串,JVM会将以常量形式的字符串放在字符串常量池中,这样下次使用的时间就可以复用这个字符串的内存空间,而不用每使用一次就分配一次内存空间去浪费内存。很多人在初学java的时间都会碰到这种问题:new String("A")创建了几个对象?str1==str2的结果是true还是false。如何解释这种问题,关键就在字符串常量池。 要想了解这个问题,首先要了解String的intern()方法。因为JVM方法区的变迁,intern()方法在不同的版本也有些许不同。

  • jdk1.6及之前的版本中:调用String.intern()方法,会先去常量池检查是否存在当前字符串,如果存在返回常量池的字符串对象,如果不存在,则会在方法区中创建一个字符串。
  • 在jdk1.7及1.8版本中:字符串常量池从方法区中的运行时常量池移到了堆内存中,而intern()方法也随之做了改变。调用String.intern()方法,首先还是会去常量池中检查是否存在,,如果存在返回常量池的字符串对象,如果不存在,那么就会创建一个常量,并将引用指向堆,也就是说不会再重新创建一个字符串对象了。

所以对于以下代码:

String str = new String("prefix") + new String("suffix");
System.out.println(str==str.intern());
复制代码

JDK1.6中会输出false,JDK1.7与JDK1.8中则会输出true. 为什么会这样呢,首先要知道new String("prefix")创建了几个对象。

在这里插入图片描述

可以看到无论是1.6之前还是之后,都是创建了两个对象,1个在堆,1个在字符串常量池。这时候执行String.intern()方法,String.intern()会去检查字符串常量池,发现字符串常量池存在prefix字符串,所以会直接返回,不管是jdk1.6还是jdk1.7和jdk1.8都是检查到字符串存在就会直接返回,所以str1==str1.intern()得到的结果就都是false,因为一个在堆,一个在字符串常量池。 而执行new String("prefix") + new String("suffix");则会创建5个对象,3个在堆中,2个在字符串常量池。 在这里插入图片描述
至此可以看到,除了字符串常量池所在的位置不同之外,堆栈的内容并没有区别。但是JDK1.6与JDK1.7的改动体现在str.intern()方法上面,使得System.out.println(str==str.intern());出现了截然不同的结果,JDK1.6中str.intern()在字符串常量池中发现并没有prefixsuffix字符串,那么就会在字符串常量池中创建一个字符串。而JDK1.7则是再去堆中查看,发现有prefixsuffix字符串对象,那么就会创建一个指向堆中字符串对象的引用放在常量池中。
在这里插入图片描述

这就会导致JDK1.6会返回false,而JDK1.7则会返回true。

参考资料:
JVM 内存区域 (运行时数据区域)
深入分析Java虚拟机堆和栈及OutOfMemory异常产生原因
细说虚拟机栈
详细解析Java虚拟机的栈帧结构
探究 Java 虚拟机栈
Java-JVM 栈帧(Stack Frame)
类中成员方法和实例方法
Java 八大基本数据类型
栈帧中动态连接的理解
符号引用与直接引用
JVM学习笔记-本地方法栈(Native Method Stacks)
浅谈Java中的栈和堆

おすすめ

転載: juejin.im/post/7076054543103950856
おすすめ