深入解析Java内存区域

Java内存区域

众所周知,Java虚拟机有自动内存管理机制,在这个机制下,我们不再 不需要像C++/C程序开发员这样为每一个new操作去写其对应的delete/free操作,这样就不容易出现内存泄漏和溢出2方面的问题。因为我们把内存控制权力交给了虚拟机,如果出现内存泄漏和溢出方面的问题,排查错误就必须要了解虚拟机内部是怎样使用内存的。

下图是JDK8之后的JVM内存布局。
在这里插入图片描述

(引自https://www.cnblogs.com/czwbig/p/11127124.html)

1、私有or共享?

按线程是否私有,我们可以将其分为:

线程私有:程序计数器、虚拟机栈、本地方法栈

线程共享:堆、方法区、直接内存(非运行时数据区的一部分)

2、线程和进程

​ 我们先来谈谈什么是线程,线程是进程中的一个实体,线程本身是不会独立存在的。进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,线程则是进程的一个执行路径,一个进程至少有一个线程,进程中的多个线程共享进程的资源。

​ 操作系统在分配资源时是把资源分配给进程的,但是CPU资源比较特殊,它是被分配到线程的,因为真正要占用CPU运行的是线程,所以线程是CPU分配的基本单位

​ 我们在Java中启动的main函数其实就是启动了一个JVM的进程,而main函数所在的线程就是这个进程中的一个线程,也称之为主线程。

​ 一个进程中有多个线程,多个线程共享进程的堆和方法区资源,所以说他们是线程共享的,而每个线程又有自己的程序计数器和栈区域。

3、各区域介绍

3.1、程序计数器

​ 程序计数器是一块较小的内存区域,用来记录线程当前要执行的指令地址。

​ 我们在前面提到程序计数器是线程私有的,那为什么要将其设计为私有呢?在上一节我们介绍到线程是占用CPu执行的基本单位,而CPU一般是使用时间片轮转方式(这不禁让我想起老式的电影放映机,就是为了节约胶卷也采用这种类似的轮转方式,间接提高帧率),让线程轮询占用的,所以当前线程CPU时间片用完后要让出CPU,等下次轮到自己的时候再执行(如果执行的是native方法,pc计数器记录是是undefined地址,只有执行的是Java代码时,计数器记录的才是下一条指令的地址)。那么如何知道之前程序执行到哪了呢?这就是程序计数器的作用力,计数器记录了该线程让出CPU时的执行地址,待再分配到时间片时线程就可以从私有的计数器指定地址继续执行。

​ 程序计数器私有使得每个线程都有一个独立的程序计数器,各计数器之间互不影响,独立存储。

​ 程序计数器的作用除了在多线程的情况下记录当前线程执行的位置,从而切换时能够知道上次运行到哪外,还有另个作用:字节码解释器通过改变程序计数器来一次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。

*注

关于其生命周期:随线程创建而创建,随线程的结束而死亡。

在这里插入图片描述

3.2Java虚拟机栈

​ 我们常说的,Java内存可以粗糙的区分为堆内存和栈内存,其中栈就是我们要说的虚拟机栈。

和计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 栈帧随着方法调用而创建,随着方法结束而销毁
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

​ 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表(存放了编译器可知的各种数据类型:boolean、byte、char、short、int、float、long、double、对象引用)、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

在活动线程中,只有位于栈顶的 栈帧才是有效的,称之为当前栈帧。当前栈帧正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。

​ 每一次函数调用都会有一个对应的栈帧被压入Java栈,每一个函数调用结束后,都会有一个栈帧被弹出。Java方法有两种返回方式:

return语句或抛出异常。不管哪种方式都会导致栈帧被弹出。

1. 局部变量表

局部变量表是存放方法参数和局部变量的区域。 局部变量没有准备阶段, 必须显式初始化。如果是非静态方法,则在 index[0] 位置上存储的是方法所属对象的实例引用,一个引用变量占 4 个字节,随后存储的是参数和局部变量。字节码指令中的 STORE 指令就是将操作栈中计算完成的局部变呈写回局部变量表的存储空间内。

虚拟机栈规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

2. 操作栈

操作栈是个初始状态为空的桶式结构栈。在方法执行过程中, 会有各种指令往
栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎, 其中的栈指的就是操
作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中。

i++ 和 ++i 的区别:

  1. i++:从局部变量表取出 i 并压入操作栈,然后对局部变量表中的 i 自增 1,将操作栈栈顶值取出使用,最后,使用栈顶值更新局部变量表,如此线程从操作栈读到的是自增之前的值。
  2. ++i:先对局部变量表的 i 自增 1,然后取出并压入操作栈,再将操作栈栈顶值取出使用,最后,使用栈顶值更新局部变量表,线程从操作栈读到的是自增之后的值。

i++ 不是原子操作,即使使用 volatile 修饰也不是线程安全,就是因为,可能 i 被从局部变量表(内存)取出,压入操作栈(寄存器),操作栈中自增,使用栈顶值更新局部变量表(寄存器更新写入内存),其中分为 3 步,volatile 保证可见性,保证每次从局部变量表读取的都是最新的值,但可能这 3 步可能被另一个线程的 3 步打断,产生数据互相覆盖问题,从而导致 i 的值比预期的小。

如我们可以从字节码的角度分析i++:

public static void main (String[] args){
    int i=0;
    i=i++;
}

编译后的字节码如下:
在这里插入图片描述
在这里插入图片描述

3. 动态链接

每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。

4.方法返回地址

方法执行时有两种退出情况:

  1. 正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等;
  2. 异常退出。

无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:

  1. 返回值压入上层调用栈帧。
  2. 异常信息抛给能够处理的栈帧。
  3. PC计数器指向方法调用后的下一条指令。

3.3、本地方法栈

​ 本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

​ 本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该方法的局部变量表、操作数栈、动态链接和出口信息。

​ 本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

3.4、堆

​ 对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

​ Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。如果从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过,无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。

3.5、方法区

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

3.6、运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

一般来说,除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。

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

3.7、直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域

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

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

​ 显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值