Java内存区域

一 概述

Java 与 C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里 面的人却想出来。

   对于从事 C、C++程序开发的开发人员来说,在内存管理领域,他们既是拥有最高权力的“皇帝”, 又是从事最基础工作的劳动人民——既拥有每一个对象的“所有权”,又担负着每一个对象生命从开始 到终结的维护责任。

   对于 Java 程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个 new 操作去写配对 的 delete/free 代码,不容易出现内存泄漏和内存溢出问题,看起来由虚拟机管理内存一切都很美好。不 过,也正是因为 Java 程序员把控制内存的权力交给了 Java 虚拟机,一旦出现内存泄漏和溢出方面的问 题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将会成为一项异常艰难的工作。

二 运行时数据区域

Java 虚拟机在执行 Java 程序过程中会把它管理的内存划分成若干个不同的数据区域,这些区域 有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是 依赖用户线程的启动和结束而建立和销毁。根据《Java 虚拟机规范》的规定,Java 虚拟机所管理的内存 将会包括以下几个运行时数据区域

线程私有的 :

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的 :

  • 方法区
  • 直接内存(非运行时数据区的一部分)

2.1 程序计数器

程序计数器 (Program Counter Register)是一块较小的内存区域,可以看作是当前线程所执行的字节码的行号指示器。也是线程私有的。在 Java 虚拟机的概念模型里,字节码解释器在工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成

由于 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何时刻,一个处理器 (对于多核处理器来说是一个内核)都只会执行一条线程中的指令,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立都程序计数器,各个线程之间计数器互不影响,独立存储,我们成这类内存为 “ 线程私有 ” 的内存,此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

2.2 Java 虚拟机栈

与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stack) 也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,虚拟机栈描述的是 Java 方法执行的线程内存模型:咩哥方法被执行的时候会同步创建一个栈帧](Stack Frame)而每个栈帧中都有:局部变量表、操作栈数、动态链接、方法出口信息,每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

局部变量表主要存放了编译期可知的各种类型 (boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

Java 虚拟机栈会出现两种错误 StackOverFlowError 和 OutOfMemoryError。

StackOverFlowError : 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈最大深度的时候,就抛出 StackOverFlowError 错误

OutOfMemoryError : Java 虚拟机栈的内存大小可以动态扩展,如果虚拟机在动态扩展时无法申请足够的内存时,则抛出 OutOfMemoryError 异常。

2.3 本地方法栈

本地方法栈(Native Method Stacks) 与 虚拟机所发挥的作用非常相似,区别是 : 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。

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

2.4 堆

Java 堆 (Heap) 虚拟机所管理的内存中华最大的一块,Java 堆是所有线程共享的一块区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象和数组都在这里分配内存,Java 世界里“几乎”所有的对象实例都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此一些资料中它也被称作 “GC 堆”(Garbage Collected Heap)从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

Java 堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的 Java 虚拟机都是按照可扩 展来实现的(通过参数-Xmx 和-Xms 设定)。如果在 Java 堆中没有内存完成实例分配,并且堆也无法再 扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。

2.5 方法区

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

2.5.1 方法区和永久代的关系

说到方法区,不得不提一下“永久代”这个概念,尤其是在 JDK 8 以前,许多 Java 程序员都习惯在 HotSpot 虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的 HotSpot 虚拟机设 计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得 HotSpot 的垃圾收集器能够像管理 Java 堆一样管理这部分内存,省去专门为方法区编写内存管理代码的 工作。但是对于其他虚拟机实现,譬如 BEA JRockit、IBM J9 等来说,是不存在永久代的概念的。原则 上如何实现方法区属于虚拟机实现细节,不受《Java 虚拟机规范》管束,并不要求统一。但现在回头 来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了 Java 应用更容易遇到 内存溢出的问题


当 Oracle 收购 BEA 获得了 JRockit 的所有权后,准备把 JRockit 中的优秀功能,譬如 Java Mission Control 管理工具,移植到 HotSpot 虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到 HotSpot 未来的发展,在 JDK 6 的 时候 HotSpot 开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计 划了[1],到了 JDK 7 的 HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了 JDK 8,终于完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间(Meta- space)来代替,把 JDK 7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

2.5.2 常用参数

JDK 1.8 的时候 (HotSpot 的永久代)被彻底移除了。取而代之的是元空间,元空间使用的是直接内存。

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

2.5.3 为什么将永久代(PermGen)替换为元空间(MetaSpace)呢?

  1. 整个永久代有一个 JVM 本身设置固定大小的上限,无法进行调整,而元空间使用的直接内存,受本季可用内存限制,虽然元空间仍 kennel 溢出,但是比原来出现的几率更小

当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

  1. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制类,而是由系统的世纪可用空间来控制,这样能加载的类就更多了。

  2. 在 JDK1.8 中,合并 HotSpot 和 JRockit 的代码时,JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

2.6 运行时常量池

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

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

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

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

2.7 直接内存

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

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

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

三 HotSpot 虚拟机对象

  • HotSpot VM,相信所有Java程序员都知道,它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。

3.1 对象的创建

Java 创建对象的过程如图:

3.1.1 类加载检查

当 Java 虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到 一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那 必须先执行相应的类加载过程,

3.1.2 分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需要的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小等内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用堆垃圾收集器是否带有压缩整理功能决定。

内存分配堆两种方式

选择以上两种方式中堆哪一种,取决于 Java 堆内存是否规整。而 Java 堆是否规整,取决于 GC 收集器的算法是 “标记-清除” ,还是 “标记-整理” ,复制算法内存也是规整的

内存分配并发问题

在创建对象的时候有个很重要的问题,就是线程安全,因为实际开发中,创建对象是很频繁的事情,做为虚拟机来说,必须要保证线程安全,通俗来讲,虚拟机采用两种方式来保证线程安全 :

  • CAS + 失败重试 : CAS 是乐观锁的一种实现,所谓乐观锁就是,每次不加锁而是假设没有冲突去完成某项操作,如果因为冲突失败就重试,直到成功。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。

  • TLAB :为每一个线程预先在 Eden 区分配一块内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中剩余内存或 TLAB 的内存已用尽时,在采用上述的 CAS 进行内存分配

3.1.3 初始化零值

内存分配完成后,虚拟机需要将分配到内存空间都初始化为零值(不包括对象头),这一步操作保证来对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

3.1.4 设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中,另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同都设置方式。

3.1.5 执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,init 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 init 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

3.2 对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 快区域:对象头 (Header)、实例数据 (Instance Data) 和 对齐填充(Padding)

Hotspot 虚拟机的对象头包括 2 部分信息,

  1. 第一类是用于存储对象自身的运行时数据,如哈 希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,
  2. 另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
  3. 第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作 用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是 任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者 2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

3.3 对象的访问定位

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

  1. 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;

  1. 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

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


本文参考书籍 :《深入理解Java虚拟机》



个人博客地址:http://blog.yanxiaolong.cn 『纵有疾风起,人生不言弃』

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值