JVM系列文章(一):Java内存区域分析

作为一个程序员,仅仅知道怎么用是远远不够的。起码,你需要知道为什么可以这么用,即我们所谓底层的东西。

那到底什么是底层呢?我觉得这不能一概而论。以我现在的知识水平而言:对于Web开发者,TCP/IP、HTTP等等协议可能就是底层;对于C、C++程序员,内存、指针等等可能就是底层的东西。那对于Java开发者,你的Java代码运行所在的JVM可能就是你所需要去了解、理解的东西。

我会在接下来的一段时间,和读者您一起去学习JVM,所有内容均参考自《深入理解Java虚拟机:JVM高级特性与最佳实践》(第二版),感谢作者。


本文是系列文章第一篇,讲述的是 Java内存区域,即在虚拟机上,数据是怎么存储的。

一、运行时数据区域


运行时数据区分为两个部分,一部分由所有线程共享,一部分是各个线程私有的。
线程共享的数据区包括方法区和堆,线程私有的数据区包括虚拟机栈、本地方法栈和程序计数器。如下图所示:

(图片来自网上图片库)

下面我们分别对这些区域进行介绍:


1.程序计数器

一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。

如果线程正在执行的是一个JAVA方法,计数器值为当前执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,计数器值为空。

这个内存区域是唯一一块绝对不会出现OutOfMemoryError的区域。


2.虚拟机栈


线程私有,它的生命周期与线程相同。描述的是Java方法执行的内存模型:
每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至完成的过程,对应一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表:

局部变量表存放了编译时可知的基本数据类型(8种,boolean等)、对象引用(指向对象起始地址的引用指针,或者是指向一个代表对象的句柄,或者是其他与此对象相关的位置)、returnAddress类型(指向字节码指令的地址)。局部变量表所需要的内存空间在编译期完成分配,在进入方法时,需要在帧中为这个方法分配多大的局部变量空间是完全确定的,运行时不改变。

局部变量表可能有两种异常状况: 如果线程请求的栈深度大于虚拟机允许的深度,抛出StackOverFlowError异常;如果虚拟机栈可以动态扩展(大多数虚拟机都可以),如果扩展时无法申请到足够的内存,就会抛出OutOfMemory异常。

3. 本地方法栈


作用与虚拟机栈类似,它们之间的区别是: 虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,本地方法栈为虚拟机使用到的Native方法服务。也会抛出StackOverFlowError和OutOfMemoryError异常。

4.Java堆


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

Java堆是垃圾收集器管理的主要区域,因此很多时候也被成为GC堆。
Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现时,既可以是固定大小的,也可以是可拓展的。如果在堆中没有内存完成实例分配,并且堆也无法再拓展时,将会抛出OutOfMemoryError异常。

5.方法区


所有线程共享,存储已被虚拟机加载的类信息、常量、静态变量、即时编辑器编译后的代码等数据。虽然这个区域有“永久代”之称,然而这个区域仍然存在内存回收,主要是针对常量池的回收和对类型的卸载。方法区也会抛出OutOfMemoryError异常。

运行时常量池:

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

6.直接内存


直接内存并不是虚拟机运行时数据区的一部分,但是这部分内存也被频繁使用,也可能导致OutOfMemoryError异常,所以放到这里一起讲。

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

受本机总内存和处理器寻址空间(比如处理器是32位的,那你能够通过地址访问到的内容就是2^32,即4G,所以你能搭配的最大内存就是4G)的限制,也会抛出OutOfMemoryError异常。



二、对象的创建、布局与访问


知道了内存中都存放了什么之后,我们自然想进一步了解虚拟机内存中的其他细节。比如是怎么创建、布局以及如何访问的。
我们以最流行的HotSpot虚拟机以及常用的内存区域Java堆为例,探讨一下对象分配、布局与访问的全过程。

1.对象的创建

我们创建对象,当然是用new指令。
虚拟机遇到new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个引用代表的类是否已经被加载、解析和初始化过。即 第一步,先去检查虚拟机加载了你要new的这个类没,如果没加载,必须先执行相应的类加载过程。(在以后的文章中会详细介绍)

然后是为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定。
分配内存有两种方式:
指针碰撞:如果Java堆中内存绝对规整,在使用的内存放在一边,空闲内存放在另一边,中间一个指针作为分界点的指示器,那分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相同的距离。
空闲列表:如果并不是规整的,虚拟机就需要维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

除了如何划分可用空间之外,还需要考虑 修改指针时的线程安全问题。可能出现正在给对象A分配内存,指针还未修改,对象B又同时使用原来的指针分配内存的情况。
解决这个问题有两种方案:
对分配内存空间的动作进行同步处理:采用CAS+失败重试的方式保证更新操作的原子性(什么是CAS:  compare-and-wwap。它的原理:我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。可以参考这篇文章: CAS原理分析
把内存分配的动作按照线程划分的不同的空间中:每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在自己的TLAB上分配,如果TLAB用完并分配新的TLAB时,再加同步锁定。

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

接下来,要对对象进行必要的设置。例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的GC分代年龄等信息, 这些信息存放在对象头之中。
最后执行根据程序员的意愿 进行初始化。


2.对象的内存布局


分为3块区域:对象头、实例数据、对齐填充。

对象头包括两部分信息:
第一部分用于存储对象自身的运行时数据,如哈希吗、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据长度在32位和64位的虚拟机中分别为32bit和64bit,称为Mark Word。这部分数据很多,超出了这么多位可以记录的限制,所以被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息。(根据不同的标志位,它的数据代表不同的含义。)
对象头的另一部分是类型指针,指向它的类元数据(元数据即关于数据的数据),虚拟机通过这个指针确定这个对象是哪个类的实例。

实例数据部分是对象真正存储的有效信息,也是在代码中所定义的各种类型的字段内容。无论是从父类继承的还是子类中定义的,都需要记录起来。

对齐填充并不是必然存在的,仅仅起着占位符的作用。HotSpot的自动内存管理系统要求对象起始地址必须是8字节的整数倍,因此当对象实例数据部分没有对齐时,需要对齐填充来补全。


3.对象的访问定位

目前主流的访问方式有使用句柄和直接指针两种。(句柄可理解成一个间接访问对象的渠道。)

使用句柄的情况:Java堆中会划分出一块内存作为句柄池,栈中的reference指向对象的句柄地址,句柄中包含了对象实例数据和类型数据各自的具体地址信息。

(图片来自网上图片库)

使用直接指针的情况:reference中存储的就是对象地址。

(图片来自网上图片库)


这两种方式 各自的优点
使用 句柄访问的最大好处就是reference中存储的是 稳定的句柄地址,对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,reference本身不需要修改。
使用 直接指针访问的最大好处就是 速度快,节省了一次指针定位的时间开销。



三、异常产生情况分析


1.Java堆溢出


只要不断地创建对象,并且保证GC roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。

要解决这个异常,一般先通过内存映像分析工具对堆转储快照分析,确定内存的对象是否是必要的(即判断是内存泄露还是内存溢出)。
如果是 内存泄露,可以进一步通过工具查看泄露对象到GC Roots的引用链,比较准确地定位出泄露代码的位置。
如果是 内存溢出,可以调大虚拟机堆参数,或者从代码上检查是否存在某些对象生命周期过长的情况。

2.虚拟机栈和本地方法栈溢出


如果线程请求的栈深度大于虚拟机栈允许的最大深度,将抛出StackOverflowError异常。如果虚拟机在拓展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

定义大量的本地变量,增大此方法帧中本地变量表的长度,达到栈允许的最大深度后,就会抛出StackOverflowError。
单线程情况下,很难抛出OutOfMemoryError异常。因为你在达到栈最大深度时,一般都还没有用完内存空间。
如果是多线程情况下,不断创建新的线程,新的线程中又不断创建新变量,可能会抛出OutOfMemoryError。


3.方法区和运行时常量池溢出


String.intern()是一个native方法,它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象,否则将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
在JDK1.6及之前的版本中,由于常量池分配在永久代中,如果不断地intern,会抛出OutOfMemoryError异常。使用JDK1.7就不会抛出。

方法区溢出的情况:一个类要被垃圾回收器回收掉,判断条件是比较苛刻的。在经常动态产生大量Class的应用中,需要特别注意类的回收状况。比如动态语言、大量JSP或者动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类。不过对于OSGi我没什么研究,以后有时间再学习吧)。


©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页