《深入理解Java虚拟机》读书笔记1——Java内存区域与内存溢出异常

时间:2023-01-02 14:00:32

1.运行时数据区域


       Java虚拟机所管理的内存将会包含以下几个运行时数据区域,如下图所示。

《深入理解Java虚拟机》读书笔记1——Java内存区域与内存溢出异常

1.1程序计数器


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

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


1.2java虚拟机栈


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

       局部变量表存放了编译期可知的各种基本数据类型、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
       在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出*Error异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时会抛出OutOfMemeryError异常。


1.3本地方法栈


      本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用的Native方法服务。
       本地方法栈区域也会抛出*Error和OutOfMemeryError异常。


1.4Java堆


      Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
       Java堆是垃圾收集器管理的主要区域,因此很多时候也被称作“GC堆”。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;在细致一点的有Eden空间,From Survivor空间、To Survivor空间等。
       Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。


1.5方法区

      方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。


1.6运行时常量池


      运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
       运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只能在编译器产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员用得比较多的便是String类的intern()方法。

1.7直接内存


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

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

2.对象访问


      Java虚拟机主流的对象访问方式有两种:使用句柄和直接指针。

      如果使用句柄访问方式,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址。

《深入理解Java虚拟机》读书笔记1——Java内存区域与内存溢出异常


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

《深入理解Java虚拟机》读书笔记1——Java内存区域与内存溢出异常


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


3.实战:OutOfMemoryError异常


3.1Java堆溢出


       Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,就会在对象数量到达最大堆容量限制后产生内存溢出异常。通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后进行分析。

       要解决这个区域的异常,一般的手段是首先通过内存映像分析工具对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必须的,也就是要先分清楚到底是出现了内存泄露还是内存溢出。

      如果是内存泄露,可进一步通过工具查看泄露对象到GC Roots的引用链。于是就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收他们的。掌握了泄露对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位泄露代码的位置。
       如果不存在泄露,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。


3.2虚拟机栈和本地方法栈溢出


      关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:

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


3.3运行时常量池溢出


      如果要向运行时常量池中添加内容,最简单的做法就是使用String.intern()这个Native方法。由于常量池分配在方法区内,我们可以通过-XX:PermSize和-XX:MaxPermSize限制方法区的大小,从而间接限制其中常量池的容量。


3.4方法区溢出

      方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这个区域的测试,基本的思路是运行时产生大量地类去填满方法区,直到溢出。
       方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收掉,判定条件是非常苛刻的。在经常动态生成大量Class的应用中,需要特别注意类的回收状况。这类场景除了上面提到的程序使用了GCLib字节码增强外,常见的还有:大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGI的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。


3.5本机直接内存溢出


       直接内存容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆的最大值一样。


《深入理解Java虚拟机》读书笔记现共有7篇文章,如下。如需了解更详细内容,可购买原书。

  1. Java内存区域与内存溢出异常
  2. 垃圾收集器与内存分配策略
  3. 类文件结构
  4. 虚拟机类加载机制
  5. 类加载及执行子系统的案例与实战
  6. Java内存模型和线程
  7. 线程安全与锁优化