Java内存区域

一、简介

JVM全称Java Virtual Machine,也就是我们耳熟能详的Java虚拟机。它能识别 .class 后缀的文件,并且能够解析其指令,最终调用操作系统,完成我们想要的操作。
在这里插入图片描述
如上图,一个Java程序,首先经过 javac 编译成 .class 文件,然后JVM将其加载到方法区,执行引擎将会执行这些字节码。执行时,会翻译成操作系统相关的函数。JVM作为 .class 文件的翻译存在,将其转为字节码调用操作系统。


JVM只是一个翻译,把 .class 翻译成机器识别的代码,但是需要注意,JVM不会自己生成代码,需要大家编写代码,这过程中需要依赖很多类库,这个时候就需要用到 JRE。

JRE除了包含JVM之外,提供了很多的类库(就是我们说的 jar 包,它可以提供一些即插即用的功能,比如读取或者操作文件,连接网络, 使用 I/O 等等之类的),这些东西就是JRE提供的基础类库。JVM标准加上实现的一大堆基础类库,就组成了Java的运行时环境,也就是我们常说的 JRE (Java Runtime Environment)。

但对于程序员来说,JRE还不够。我写完要编译代码,还需要调试代码,还需要打包代码、有时候还需要反编译代码。所以我们会使用JDK,因为JDK还提供了一些非常好用的小工具,比如 javac(编译代码)、java、jar(打包代码)、javap(反编译<反汇编>)等,这个就是 JDK。


二、运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域
在这里插入图片描述

数据区域说明参数调整
虚拟机栈线程私有,每个线程在创建时都会创建一个虚拟机栈,
其内部保存一个个的栈帧,对应着一次次的java方法调用,
方法的执行就对应着栈帧在虚拟机栈中入栈和出栈的过程。
-Xss:调整虚拟机栈的大小
程序计数器较小的内存空间,当前线程执行的字节码的行号指示器;
各线程之间独立存储,互不影响。
/
本地方法栈本地方法栈保存的是native方法的信息,当一个JVM创建的线程
调用native方法后,JVM不再为其在虚拟机栈中创建栈帧,
JVM只是简单地动态链接并直接调用native方法。
/
Java堆是Javaer需要重点关注的一块区域,因为涉及到内存
的分配(new关键字,反射等)与回收(回收算法,收集器等) 。
-Xms:堆的最小值
-Xmx:堆的最大值

-XX:NewSize:新生代最小值
-XX:MaxNewSize:新生代最大值
(优先级高)

-Xmn:新生代的大小(中)
(NewSize=MaxNewSize)

-XX:NewRatio:表示比例(低)
例如=2,表示 新生代 : 老年代 = 1:2

-XX:SurvivorRatio:表示Eden和Survivor的比值
缺省值为8表示 Eden:FromSurvivor:ToSurvivor=8:1:1
方法区也叫永久区,用于存储已经被虚拟机加载的类信息,
常量(“rocky”,"0107"等),静态变量(static变量)等数据。
jdk1.6版本及以前:
-XX:PermSize
-XX:MaxPermSize

jdk1.7版本:
-XX:MetaspaceSize
-XX:MaxMetaspaceSize

jdk1.8以后大小就只受本机总内存的限制
运行时常量池运行时常量池是方法区的一部分,用于存放编译期生成的
各种字面量(“rocky”,"0107"等)和符号引用。
/

这里我们我们主要看一下上表中的各个内存区域在实现在不同的Jdk版本中,也是存在一定的差异的,如下:
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
永久代来存储类信息、 常量、静态变量等数据不是个好主意,很容易遇到内存溢出的问题。并且对永久代进行调优是很困难的,同时将元空间与堆的垃圾回收进行了隔离, 避免永久代引发的Full GC和OOM等问题。


2.1、虚拟机栈

先进后出(FILO)的数据结构,虚拟机栈的大小缺省为1m,可用参数–Xss调整大小,例如 -Xss256k

每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应着一次次的java方法调用,每个方法在执行的同时都会创建一个栈帧,方法的执行就对应着栈帧在虚拟机栈中入栈和出栈的过程。

在每个java方法被调用的时候,都会创建一个栈帧并入栈,一旦方法完成相应的调用则出栈,栈帧里面存放着各种基本数据类型和对象的引用。栈帧主要包含四个区域:局部变量表、操作数栈、动态连接、返回地址
在这里插入图片描述

2.1.1、局部变量表

局部变量表是一组变量值存储空间,顾名思义就是用于存放方法参数和方法内部定义的局部变量。在java程序编译成class文件时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

它是一个32位的长度,主要存放我们的java的八大基础数据类型,一般32位就可以存放下,如果是64位的就使用高低位占用两个也可以存放下,如果是局部的一些对象,比如我们的Object对象,我们只需要存放它的一个引用地址即可。

局部变量表中的容量是以变量槽为最小单位。另外不是说我们的方法中定义了多少个局部变量,就必须有多少个变量槽,这方法中如果一个局部变量使用完成后,它的变量槽是可以被其他局部变量所复用的。


2.1.2、操作数据栈

操作数栈又常称为操作栈,它是一个先进后出、后入先出的栈。同局部变量表一样,操作数栈的最大深度在编译的时候写入到Code属性的max_stacks数据项中。

操作数栈的每一个元素都可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令在操作数栈中写入和提取内容,也就是出栈/入栈。例如,在做算术运算的时候是通过操作数栈来进行的,又或者是调用其他方法的时候是通过操作数栈来进行参数传递的。


举例,整数加法的字节码指令 iadd 在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个 int 型的数值,当执行这个指令时,会将这两个 int 值出栈并相加,然后将相加的结果入栈。


2.1.3、动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

在class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接


2.1.4、返回地址

当一个方法开始执行后,只要两种方式可以退出这个方法。第一种方式就是执行引擎遇到任意一个方法返回的字节码指令,这种方式成为正常完成出口

另一种退出方式就是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只有在本方法的异常表中没有搜索在匹配的异常处理器,就会导致方法退出,这种退出方法称为异常完成出口。这种退出是不会给它的上层调用者产生任何返回值的。


无论采用何种退出方法,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回是可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。


2.1.5、基于栈的字节码解释执行引擎

在了解完上述虚拟机栈中的概念后,再来看看Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,他们依赖操作数栈进行工作,其的优点可移植。

另外还有一种就是基于寄存器的指令集,比如x86的二进制指令集,现在我们主流的PC机中直接支持的指令集架构都是基于寄存器指令集,它的优点就是和硬件连接较为紧密,速度快。

这里我们简单看一个例子,如 1+1,基本栈的指令集就是,两条iconst_1指令连续把两个常量 1 压入栈后,iadd指令把栈顶两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot(变量槽)中,如下:

iconst_1
iconst_1
iadd
istore_0

这里可以结合一个实例,使用javap -verbose查看其class文件,来进行介绍一下我们基于栈的解释器执行过程。

public class Client {
    public int calc() {
        int a = 100;
        int b = 200;
        int c = 300;
        return (a + b) * c;
    }
}

在这里插入图片描述


2.1.6、栈的优化技术——栈帧之间数据的共享

在一般的情况下,两个不同的栈帧的内存区域是独立的,但是大部分的JVM在实现中会进行一些优化,使得两个栈帧出现一部分重叠。(主要体现在方法中有参数传递的情况),让下面栈帧的操作数栈和上面栈帧的部分局部变量重叠在一起,这样做不但节约了一部分空间,更加重要的是在进行方法调用时就可以直接公用一部分数据,无需进行额外的参数复制传递了。
在这里插入图片描述


2.2、程序计数器

程序计数器是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。

由于Java是多线程语言,当执行的线程数量超过CPU核数时,线程之间会根据时间片轮询争夺CPU资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的CPU资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。


因为JVM是虚拟机,内部有完整的指令与执行的一套流程,所以在运行java方法的时候需要使用程序计数器(记录字节码执行的地址或行号),如果是遇到本地方法(native方法),这个方法不是JVM来具体执行,所以程序计数器不需要记录了,这个是因为在操作系统层面也有一个程序计数器,这个会记录本地代码的执行的地址,所以在执行native方法时,JVM中程序计数器的值为空(undefined)。

另外程序计数器也是JVM中唯一不会OOM(OutOfMemory)的内存区域。


2.3、本地方法栈

本地方法栈跟Java虚拟机栈的功能类似,Java虚拟机栈用于管理Java函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用Java实现的,而是由C语言实现的(比如 Object.hashcode方法)。

本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是native方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域。(虚拟机规范无强制规定,各版本虚拟机自由实现 ,HotSpot直接把本地方法栈和虚拟机栈合二为一)


2.4、堆

堆是JVM上最大的内存区域,是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内容区域的唯一目的就是存放对象实例,几乎所有的对象实例都是在这里分配内存的。

从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以堆空间还可以细分为:新生代和老年代;再细致一点的新生代还分为:Eden空间、From Survivor空间、To Survivor空间。

从内容分配的角度来看,线程共享的Java堆中可能划分多个线程私有的分配缓冲区(TLAB)。


2.5、方法区

方法区是供各个线程共享的运行时内存区域。它存储了每一个类的结构信息,方法区是JVM对内存的“逻辑划分”,在JDK1.7及之前都习惯将方法区称为“永久代”,是因为在HotSpot虚拟机中,使用了永久代来实现了JVM规范的方法区,在JDK1.8及以后使用了元空间来实现方法区。

方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。

在 HotSpot 虚拟机、Java7版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在JVM的非堆内存中,而Java8版本已经将方法区中实现的永久代去掉了,并用元空间代替了之前的永久代,并且元空间的存储位置是本地内存。


2.6、运行时常量池

运行时常量池是每一个类或接口的常量池的运行时表示形式,它包括了若干种不同的常量:从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。

运行时常量池是在类加载完成之后,将Class常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。运行时常量池在JDK1.7版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。

在JDK1.8中,使用元空间代替永久代来实现方法区,但是方法区并没有改变。变动的只是方法区中内容的物理存放位置,但是运行时常量池和字符串常量池被移动到了堆中。但是不论它们物理上如何存放,逻辑上还是属于方法区的。


三、直接内存

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

其实它不是虚拟机运行时数据区的一部分, 也不是Java虚拟机规范中定义的内存区域。在我们使用了NIO时,这块区域会被频繁使用,在java堆内可以用directByteBuffer对象直接引用并操作,这块内存不受java堆大小限制, 但受本机总内存的限制, 可以通过-XX:MaxDirectMemorySize 来设置( 默认与堆内存最大值一样) , 所以也会出现OOM异常。


直接内存的作用是什么呢?比如在网络通信之中,我们系统从外部读取或接收到一些数据,这里是有我们系统去接收管理的,属于系统态,但是我们JVM虚拟机上运行的程序属于用户态,要是我们的程序想要使用系统态中的数据,这里必须进行拷贝一下,从系统态拷贝到用户态之中,而我们的直接内存就是可以避免其中拷贝的过程。


使用直接内存的优势:

  1. 减少了垃圾回收的工作,GC过程中会存在暂停应用线程
  2. 加快了复制的速度。因为堆内在 flush 到远程时,会先复制到直接内存(非堆内存),然后再发送,而堆外内存相当于省略掉了这个工作
  3. 可以在进程间共享,减少 JVM 间的对象复制,使得 JVM 的分割部署更容易实现

直接内存除了优势,当然也存在很多缺点:

  1. 难以控制,如果内存泄漏,那么很难排查
  2. 相对来说,不适合存储很复杂的对象,一般简单的对象比较适合

四、JHSDB工具

JHSDB 是一款基于服务性代理实现的进程外调试工具。服务性代理是HotSpot虚拟机中一组用于映射Java虚拟机运行信息的,主要基于 Java 语言实现的API集合。

我们可以用来查看其执行中程序实际堆、栈的情况,结合上述介绍的内存区域进行理解,至于如果开启JHSDB工具,这里还需要注意下JDK版本的区别。

  • JDK1.8启动JHSDB的时候必须将 sawindbg.dll(一般会在jdk的目录下)复制到对应目录的 jre 下(注意在windows上安装了JDK1.8后往往同级目录下有一个jre的目录),然后在jdk目录下的 lib 文件夹下进入命令行,执行java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB
  • JDK1.9及以后,可以直接进入 jdk 的 bin 目录下,在命令行中使用jhsdb hsdb来启动
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值