JVM - 内存区域详解

对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像C/C++程序开发程序员这样为内一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
在这里插入图片描述
这些组成部分一些事线程私有的,其他的则是线程共享的。
线程私有的:

  1. 程序计数器
  2. 虚拟机栈
  3. 本地方法栈

线程共享的:

  1. 方法区
  2. 直接内存

程序计数器

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

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

从上面的介绍中我们知道程序计数器主要有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注意:程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

假设我们有如下的一个类,就是最最基本的一个HelloWorld而已:

public class HelloWorld {
	public static void main(String[] args) {
		System.out.println("Hello World");
	}
}

上面这段代码首先会存在于 “.java” 后缀的文件里,这个文件就是java源代码文件,但是这个文件是面向我们程序员的,计算机看不懂这段代码。所以此时就得通过编译器,把“.java”后缀的源代码文件编译为“.class”后缀的字节码文件。这个“.class”后缀的字节码文件里,存放的就是对你写出来的代码编译好的字节码了。这个字节码才是计算器可以理解的一种语言,而不是我们写出来的那一堆代码

所以现在首先明白一点:我们写好的Java代码是会被翻译成字节码的,对应各种字节码指令。那么Java代码通过JVM跑起来的第一件事情就明确了。

接下来,在执行字节码指令时,JVM里的程序计数器就是用来记录每个线程当前执行的字节码指令的位置的,记录当前线程目前执行到了哪一条字节码指令。

因为会有多个线程来并发的执行各种不同的代码,所以每个线程都有自己的一个程序计数器,专门记录当前这个线程目前执行到了哪一条字节码指令了。

下图更加清晰的展示出了他们之间的关系:
在这里插入图片描述

Java 虚拟机栈

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型。Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。

Java代码在执行的时候,一定是线程来执行某个方法中的代码,哪怕上面那个最基础的HelloWorld代码,也会有一个main线程来执行main方法里的代码。

在方法里,经常会定义一些方法内的局部变量,比如下面这样,就在方法里定义了一个局部变量“name”:

public void sayHello() {
	String name = "hello";
}

所以JVM必须有一块区域来保存每个方法内的局部变量等数据,这个区域就是Java虚拟机栈。每个线程都会去执行各种方法的代码,方法内还会嵌套调用其他的方法,所以每个线程都有自己的Java虚拟机栈。

如果线程执行了一个方法,那么就会为这个方法调用创建对应的一个栈帧,栈帧里有这个方法的局部变量表 、操作数栈、动态链接、方法出口等东西。比如一个线程调用了上面写的 “sayHello” 方法,那么就会为“sayHello”方法创建一个栈帧,压入线程自己的Java虚拟机栈里面去,在栈帧的局部变量表里就会有“name”这个局部变量。如下图所示:
在这里插入图片描述
接着如果“sayHello”方法调用了另外一个“greeting”方法 ,比如下面那样的代码:

public void sayHello() {
	String name = "hello";
	greeting(name);
}
public String greeting(String name) {
	String greeting = name + ", greeting";
	System.out.println(greeting);
}

这时会给“greeting”方法又创建一个栈帧,压入线程的Java虚拟机栈里,因为开始执行“greeting”方法了。而且“greeting”方法的栈帧的局部变量表里会有一个“greet”变量,这是“greeting”方法的局部变量。
在这里插入图片描述
接着如果“greeting”方法执行完毕了,就会把“greeting”方法对应的栈帧从Java虚拟机栈里给出栈。然后接下来如果“sayHello”方法也执行完毕了,就会把“sayHello”方法也从Java虚拟机栈里出栈。

这就是JVM中的 “ Java虚拟机栈 ” 这个组件的作用:调用执行任何方法的时候,都会给方法创建栈帧,然后入栈。而在栈帧里存放了这个方法对应的局部变量之类的数据,包括这个方法执行的其他相关的信息,方法执行完毕之后就出栈。

Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

  1. StackOverFlowError: 若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
  2. OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。

Java 虚拟机栈也是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

本地方法栈

其实在JDK很多底层API里,比如IO相关的,NIO相关的,网络Socket相关的,如果去看他内部的源码,会发现很多地方都不是Java代码了。很多地方都会去走native方法,去调用本地操作系统里面的一些方法,可能调用的都是c语言写的方法,或者一些底层类库,比如下面这样的:

public native int hashCode();

在调用这种 native 方法时,就会有线程对应的本地方法栈。和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

Java堆

JVM中有另外一个非常关键的区域,就是Java堆,这里就是存放我们在代码中创建的各种对象的,比如说下面的代码:

public void sayHello(String name) {
    Student student = new Student(name);
    student.study();
}

上面的 “new Student(name)” 这个代码,就是创建了一个Student类型的对象实例,这个对象实例里面会包含一些数据。比如说这个Student的“name”就是属于这个对象实例的数据,类似Student这样的对象,就会存放在Java堆内存里。

Java堆内存区域里会放入类似Student的对象,然后方法的栈帧的局部变量表里,会存放这个引用类型的“student”局部变量,即存放Student对象的地址。相当于你可以认为局部变量表里的“student”指向了Java堆里的Student对象。
在这里插入图片描述
Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:在细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
在这里插入图片描述
在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域,永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制。
推荐阅读:
《Java8内存模型—永久代(PermGen)和元空间(Metaspace)》:http://www.cnblogs.com/paddix/p/5309550.html

方法区 / Metaspace

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

这个方法区是在JDK 1.8以前的版本里,代表JVM中的一块区域,它主要是放类似Student类自己的信息的,平时用到的各种类的信息,都是放在这个区域里,还会有一些类似常量池的东西放在这个区域里。

但是在JDK 1.8以后,这块区域的名字改了,叫做“Metaspace”,可以认为是“元数据空间”这样的意思,当然他主要还是存放我们自己写的各种类相关的信息。

运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)。既然运行时常量池时方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

JDK1.7及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError异常出现。

JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。
这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

对象的创建

通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。下图便是 Java 对象的创建过程:
在这里插入图片描述

  1. 类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  2. 分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
    在这里插入图片描述
  3. 初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 设置对象头: 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
  5. 执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,init 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 init 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的访问定位

建立对象就是为了使用对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有使用句柄直接指针两种:

  1. 使用句柄: 如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息:
    在这里插入图片描述
  2. 使用指针: 如果使用直接指针访问,那么 Java 堆对像的布局中就必须考虑如何放置访问类型数据的相关信息,而reference 中存储的直接就是对象的地址:
    在这里插入图片描述

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

无法无天过路客

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

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

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

打赏作者

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

抵扣说明:

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

余额充值