《深入java虚拟机》读书笔记之Java内存区域

813 阅读13分钟

前言

该读书笔记用于记录在学习《深入理解Java虚拟机——JVM高级特性与最佳实践》一书中的一些重要知识点,对其中的部分内容进行归纳,主要是方便之后进行复习。

运行时数据区域

Java虚拟机在执行过程中会将其管理的内存划分为多个不同的数据区域。其中一些区域随着虚拟机启动而创建,一些区域生命周期则依赖用户线程的启动和结束。

下面是JDK1.7

JDK1.7

程序计数器

是一块较小的内存空间,用于记录当前线程所执行的字节码的行号,在执行过程中通过改变计数器的值来选择下一条被执行的指令。分支、循环、异常处理等都通过程序计数器实现。

在多线程环境下,CPU在不同线程间进行切换时,为了保证CPU下一次切换到线程时能继续之前的执行轨迹进行,需要时用程序计数器记录下切换前执行到哪一步。该区域为各个线程私有,互不干扰。该区域不会发生OOM。

如果线程在执行一个java方法,那么程序计数器将会记录正在执行的虚拟机字节码的指令地址。而如果正在执行的是一个native方法,计数器的值则为空。

Java虚拟机栈

Java虚拟机栈由线程私有,其生命周期同线程一样。Java虚拟机栈主要是用于描述Java程序中方法执行时的内存模型。

每一个方法在执行的时候都会创建一个栈帧,栈帧里存储局部变量表、操作数栈,动态链接等信息。每一个方法从开始调用到执行结束的过程就对应着一个栈帧在java虚拟机栈的入栈到出栈的过程。

栈帧

栈帧是支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表,操作数栈,动态链接和方法返回地址等信息。每一个方法从调用开始到执行结束都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  • 局部变量表是一组变量值存储空间,用于存储方法参数和方法内部定义的变量。
  • 操作数栈用于执行一个方法,本质是一个栈。如对于两数相加,会先将两个数入栈,执行加法操作是将两个数出栈相加然后将结果入栈。
  • 动态链接是指在运行期间才能确定具体调用某一个方法。
  • 方法返回地址表示一个方法完成后返回到调用方的地址,方法返回可以使正常返回或者未被处理的异常。

本地方法栈

本地方法栈和虚拟机栈的作用一样,不同之处在于java虚拟机栈服务于虚拟机中的字节码(java方法)。而本地方法栈则是为虚拟机使用native修饰的方法服务。

Java堆

java堆是Java虚拟机管理的内存中最大的一块,同时堆被所有的线程所共享,堆内存在虚拟机启动是被创建。Java堆的作用在于存放对象的实例,几乎所有的对象实例都在Java堆上分配内存。同时Java堆也是垃圾收集器管理的主要区域,根据分代收集算法还可以将堆分为新生代和老年代,更细致的可以分为Eden区、from区、to区。

方法区

方法区和堆一样被各个线程所共享。方法区用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区也被称为“永久代”(PermGen)。

运行时常量池

运行时常量池在JDK1.7之前是方法区的一部分,在JDK1.7的时候运行时常量池就被移到堆中。常量池用于保存编译期生成的各种字面量和符号引用,但并不是说只有编译期才能生成常量。在运行时也可以将新的常量放入常量池中(String的intern()方法)。

直接内存

直接内存并不是Java虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,所以直接内存的大小不受Java堆大小的影响,但是还是受宿主机内存大小影响,也会发生OOM。在Java中使用该区域的典型代表就是NIO。

元空间(MetaSpace)

在JDK1.8之后,HotSpot JVM移除了方法区,而使用本地化内存来代替,这块区域被称为“元空间”。“永久代”被移除使得JVM参数PermSize 和MaxPermSize会被忽略,当前在启动时会有警告信息。添加了一个MaxMetaspaceSize对元数据区大小进行调整。默认情况下,类元数据分配受到可用的本机内存容量的限制。

为什么要移除方法区?

使用永久代来存储类信息、常量、静态变量等数据不是个好主意, 很容易遇到内存溢出的问题.JDK8的实现中将类的元数据放入 本地内存, 将字符串池和类的静态变量放入java堆中。同时对永久代进行调优是很困难的,同时将元空间与堆的垃圾回收进行了隔离,避免永久代引发的Full GC和OOM等问题。

各个JDK版本的运行时数据区域一览

JDK1.6

JDK1.6

JDK1.7

JDK1.7

JDK1.8

JDK1.8

内存区域内的异常

堆栈溢出

Eorror

主要发生的异常分为两种:OutOfMemeryErrorStackOverFlowError。其中OutOfMemeryError是当Java虚拟机由于内存不足而无法分配对象时抛出,并且垃圾收集器不再有可用的内存。而StackOverFlowError是当堆栈溢出发生时抛出的。

在内存区域的各部分中,程序计数器不会发生内存溢出的情况。

虚拟机栈和本地方法栈用于存储方法执行的顺序,当方法的调用层次过深(递归)时,可能会导致分配的栈内存不足时将会抛出StackOverFlowError的异常。从上图我们可以看出这一块区域还会抛出OutOfMemeryError。这是因为挡在多线程情况下,虚拟机中大量的线程进行方法调用导致创建的栈帧创建过多使得Java虚拟机由于内存不足而无法分配对象。

对于Java堆和方法区可能会由于程序中创建的实例过多而导致OutOfMemeryError

如果线程请求的栈深度大于虚拟机锁允许的最大深度,将抛出StackOverFlowError,StackOverFlowError一般是函数调用层级过多导致,比如死递归、死循环。这类异常一般需要我们检查代码是否存在逻辑上的问题。

如果虚拟机在扩展栈时无法申请到足够的内存空间或者堆中存在着大量无法被gc的类信息,将抛出OutOfMemeoryError,前者一般是在多线程环境才会产生,一般用“减少内存的方法”,既减少最大堆和减少栈容量来换取更多的线程支持。减少最大堆使得栈可被分配的内存变大,可以容纳更多的线程,减少栈容量使得可创建的栈帧数量变大。后者需要我们dump出堆异常模型检查问题。如果是内存溢出则调整堆的大小,内存泄露则需要检查相关代码。

方法区或元数据区溢出

方法区和元数据区用于存放class的相关信息,当工程中类比较多,而方法区或者元数据区太小,在启动时容易抛出OOM异常。

JDK1.7之前,通过-XX:PermSize,-XX:MaxPerSize,调整方法区的大小;

JDK1.8后,通过-XX:MetaspaceSize ,-XX:MaxMetaspaceSize,调整元数据区的大小;

本机直接内存溢出

jdk本身很少操作直接内存,而直接内存(DirectMemory)导致溢出最大的特征是:Heap Dump文件不会看到明显异常,而程序中直接或者间接的用到了NIO。

直接内存不受java堆大小限制,但受本机总内存的限制,可以通过MaxDirectMemorySize来设置。

对象的创建

在我们平时使用Java语言创建对象时,使用最多的肯定是通过new关键字完成,当然还有其他的方式如反射,克隆等。我们使用很简单,但是实际上关于创建一个对象在虚拟机中是做了大量的事情的。

当虚拟机检测到一条new指令时,首先到常量池中检测new指令所携带的参数是否能和常量池中的某一符号引用所对应,如果找到了对应的符号引用还需要检查其所代表的类是否已经完成了加载、解析、初始化等过程。如果以上条件不满足那么还需要先进行类加载过程。

内存分配

当完成了类加载过程后就需要对对象进行内存分配了,为一个对象分配内存的大小实际上在类加载过程中就已经确定下来,这里的内存分配过程就是讲一块确定大小的内存从Java堆中划分出来。

指针碰撞(Bump the Pointer)

假设堆中的内存是一块绝对规整的,即被使用的内存和没有被使用的内存有着明确的划分,一边是使用过的,一边是没有使用的,存在着一个指针作为分界的标志。那么分配过程实际上就是讲指针像没有被使用的区域移动确定大小的距离。这种分配方式被称为“指针碰撞”。

空闲列表(Free List)

实际上很多情况下内存区域并非像上面那种情况,而是已经使用过的内存和没使用过的相互交错,这时没有办法使用指针碰撞了。虚拟机会维护一个用于记录那些内存是可用的的列表。在内存分配时找出一块足够大小的地方划分给对象。这种方式被称为“空闲列表”。

使用哪种方式

从上面的描述可知使用哪种方式是由Java堆是否规整来决定,而Java堆是否规整则由使用的垃圾收集器是否带有压缩整理功能决定。

  • 指针碰撞:使用Serial,ParNew等带有Compact过程的收集器。
  • 空闲列表:使用CMS这种基于Mark-Sweep算法的收集器。

保证内存分配的安全性

在程序运行过程中,对象创建时非常频繁的事情,在多线程情况下这个过程就变得非常危险。

同步

通过对对象创建过程进行同步保证在并发下的安全性,在虚拟机中通过CAS+失败重试的方式保证原子性。

TLAB(thread location allocation buffer)

通过预先在Java堆中为不同的线程分配一块内存将线程间的内存分配过程隔离开来,这块内存区域被称为线程本地缓冲(thread location allocation buffer)。线程进行内存分配时在TLAB上进行。只有当TLAB不足需要重新分配时才需要同步操作。虚拟机是否使用TLAB可以通过参数-XX:+/-UseTLAB来控制。

初始化

上面的内存分配过程完成后,就需要将分配到的内存都初始化为零值。同时设置对象信息,例如该对象是哪个类的实例、对象的哈希码、对象的GC分代年龄等信息。这些信息都是存放在对象的对象头中。

以上过程完成后会调用方法对对象的信息进行初始化,就是调用构造方法。

对象内存布局

在HotSpot Jvm中,对象的内存布局主要分为三个区域:

  1. 对象头(Header)
  2. 实例数据(Instance Data)
  3. 对其填充(Padding)

对象头(Header)

对象头中主要包含两部分,第一部分用于存储对象自身的运行时数据如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这一部分的数据在32位和64位虚拟机中的长度分别为32bit和64bit。

对象的另一部分是类型指针,也即是一个对象指向它所属类的指针。虚拟机可以通过这个指针确定对象属于具体哪一个类,当然这一过程并不是一定得,查找对象的所属类并不是一定要通过对象本身。如果存储的是一个数组,那么对象头中还会存储该数组的长度。

实例数据(Instance Data)

实例数据是用于存储程序代码中定义的各个字段的内容,包括从父类继承的和子类重新定义的。该部分的存储顺序受到虚拟机分配策略参数和字段在源代码中定义的数据顺序影响。HotSpot Jvm中默认的分配策略为longs/doubles,ints,shorts/chars,bytes/booleans,oops(Ordinary Object Points),即相同大小的字段会被分配在一起。在满足分配策略的条件下父类中的字段会被分配在子类的前面。

如果CompactFields为true(默认),那么子类中较小的变量会被插入到父类的空隙中取。

对其填充(Padding)

这一部分并不是必要的,仅仅起到占位符的作用,除此外没有其他的作用。因为HotSpot虚拟机的自动内存管理系统要求对象的大小要求时8字节的倍数,对象头部分固定为8的倍数,而实例数据如果大小不到8的倍数就由对其填充来补充。

对象的访问定位

对象是在堆区域创建,为了使用对象,我们需要通过栈上的reference数据来操作堆上的对象。目前主流的访问方式有两种:

  • 句柄访问
  • 直接指针访问

句柄访问

使用句柄访问的方式需要在堆上额外分配出一块区域来作为句柄池,在栈上的reference数据则存储对象的句柄地址而句柄则包括对象实例数据和类型数据的地址。

句柄访问

//图来自《深入理解java虚拟机》

直接指针访问

直接指针访问的方式中reference存储的是对象的实例地址,而对象类型数据的地址则是存储在对象的实例中。

直接指针访问

//图来自《深入理解java虚拟机》

使用句柄方式是reference数据不存储对象的具体地址而是通过句柄来指向,当对象移动(垃圾回收)后也不需要修改reference中的值。

使用直接指针访问好处在于直接定位到对象实例数据,不需要经过句柄这一次定位,在频繁的对象定位过程中能有效提升效率,HotSpot使用直接指针定位方式。