深入理解Java虚拟机:JVM高级特性与最佳实践(第三版)-笔记(2)-自动内存管理-Java内存区域与内存溢出异常

第二部分 自动内存管理

一、导读

·第2章 Java内存区域与内存溢出异常
·第3章 垃圾收集器与内存分配策略
·第4章 虚拟机性能监控、 故障处理工具
·第5章 调优案例分析与实战

二、第2章 Java内存区域与内存溢出异常

1、运行时数据区域(对应文章2.2)

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

  • 程序计数器 

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

  •  Java虚拟机栈

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

  • 本地方法栈

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

  • Java堆

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

  • 方法区

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

  • 运行时常量池

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

  • 直接内存

  直接内存( Direct Memory) 并不是虚拟机运行时数据区的一部分, 也不是《 Java虚拟机规范》 中
定义的内存区域。 但是这部分内存也被频繁地使用, 而且也可能导致OutOfMemoryError异常出现, 所
以我们放到这里一起讲解。
在JDK 1.4中新加入了NIO( New Input/Output) 类, 引入了一种基于通道( Channel) 与缓冲区
( Buffer) 的I/O方式, 它可以使用Native函数库直接分配堆外内存, 然后通过一个存储在Java堆里面的
DirectByteBuffer对象作为这块内存的引用进行操作。 这样能在一些场景中显著提高性能, 因为避免了
在Java堆和Native堆中来回复制数据。
显然, 本机直接内存的分配不会受到Java堆大小的限制, 但是, 既然是内存, 则肯定还是会受到
本机总内存( 包括物理内存、 SWAP分区或者分页文件) 大小以及处理器寻址空间的限制, 一般服务
器管理员配置虚拟机参数时, 会根据实际内存去设置-Xmx等参数信息, 但经常忽略掉直接内存, 使得
各个内存区域总和大于物理内存限制( 包括物理的和操作系统级的限制) , 从而导致动态扩展时出现
OutOfMemoryError异常。

2、HotSpot虚拟机对象探秘(对应文章2.3)

  • 对象的创建

  当Java虚拟机遇到一条字节码new指令时, 首先将去检查这个指令的参数是否能在常量池中定位到

一个类的符号引用, 并且检查这个符号引用代表的类是否已被加载、 解析和初始化过。 如果没有, 那

 必须先执行相应的类加载过程--详细的会在第7章介绍

  • 对象的内存布局

  在HotSpot虚拟机里, 对象在堆内存中的存储布局可以划分为三个部分: 对象头( Header) 、 实例
数据( Instance Data) 和对齐填充( Padding) 。

  • 对象的访问定位

  创建对象自然是为了后续使用该对象, 我们的Java程序会通过栈上的reference数据来操作堆上的具
体对象。

 

  主流的访问方式主要有使用句柄和直接指针:

  如果使用句柄访问的话, Java堆中将可能会划分出一块内存来作为句柄池, reference中存储的就
是对象的句柄地址, 而句柄中包含了对象实例数据与类型数据各自具体的地址信息, 其结构如图

  如果使用直接指针访问的话, Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关
信息, reference中存储的直接就是对象地址, 如果只是访问对象本身的话, 就不需要多一次间接访问
的开销,如图

 

 

  两种方式优势:

  使用句柄来访问的最大好处就是reference中存储的是稳定句柄地
址, 在对象被移动( 垃圾收集时移动对象是非常普遍的行为) 时只会改变句柄中的实例数据指针, 而
reference本身不需要被修改。  

  使用直接指针来访问最大的好处就是速度更快, 它节省了一次指针定位的时间开销, 由于对象访
问在Java中非常频繁, 因此这类开销积少成多也是一项极为可观的执行成本, 就本书讨论的主要虚拟
机HotSpot而言, 它主要使用第二种方式进行对象访问( 有例外情况, 如果使用了Shenandoah收集器的
话也会有一次额外的转发, 具体可参见第3章) , 但从整个软件开发的范围来看, 在各种语言、 框架中
使用句柄来访问的情况也十分常见。

 

3、实战: OutOfMemoryError异常(对应文章2.4)

  • Java堆溢出

  Java堆用于储存对象实例, 我们只要不断地创建对象, 并且保证GC Roots到对象之间有可达路径
来避免垃圾回收机制清除这些对象, 那么随着对象数量的增加, 总容量触及最大堆的容量限制后就会
产生内存溢出异常。

  Java堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。 出现Java堆内存

溢出时, 异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“Java heap space”。

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

  如果线程请求的栈深度大于虚拟机所允许的最大深度, 将抛出StackOverflowError异常。

  如果虚拟机的栈内存允许动态扩展, 当扩展栈容量无法申请到足够的内存时, 将抛出

OutOfMemoryError异常。

  注:《 Java虚拟机规范》 明确允许Java虚拟机实现自行选择是否支持栈的动态扩展, 而HotSpot虚拟机

的选择是不支持扩展, 所以除非在创建线程申请内存时就因无法获得足够内存而出现
OutOfMemoryError异常, 否则在线程运行时是不会因为扩展而导致内存溢出的, 只会因为栈容量无法
容纳新的栈帧而导致StackOverflowError异常。

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

  1)在JDK 6或更早之前的HotSpot虚拟机中, 常量池都是分配在永久代中, 我们可以通过

-XX: PermSize和-XX: MaxPermSize限制永久代的大小,JDK6运行时常量池溢出时, 在OutOfMemoryError异常后面跟随的提示信息

是“PermGen space”, 说明运行时常量池的确是属于方法区( 即JDK 6的HotSpot虚拟机中的永久代) 的一部分。
  

  2)在JDK 7中继续使用-XX: MaxPermSize参数或者在JDK 8及以上版本使用-XX: MaxMeta-spaceSize参数把方法区容量同

样限制在6MB, 也都不会重现JDK 6中的溢出异常, 循环将一直进行下去, 永不停歇。 出现这种变
化, 是因为自JDK 7起, 原本存放在永久代的字符串常量池被移至Java堆之中, 所以在JDK 7及以上版
本, 限制方法区的容量对该测试用例来说是毫无意义的。 

  3)在JDK 8以后, 永久代便完全退出了历史舞台, 元空间作为其替代者登场。 在默认设置下, 前面
列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。 不过
为了让使用者有预防实际应用里出现类似于代码清单2-9那样的破坏性的操作, HotSpot还是提供了一
些参数作为元空间的防御措施, 主要包括:
·-XX: MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存
大小。
·-XX: MetaspaceSize: 指定元空间的初始空间大小, 以字节为单位, 达到该值就会触发垃圾收集
进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放
了很少的空间, 那么在不超过-XX: MaxMetaspaceSize( 如果设置了的话) 的情况下, 适当提高该
值。
·-XX: MinMetaspaceFreeRatio: 作用是在垃圾收集之后控制最小的元空间剩余容量的百分比, 可
减少因为元空间不足导致的垃圾收集的频率。 类似的还有-XX: Max-MetaspaceFreeRatio, 用于控制最
大的元空间剩余容量的百分比。

  • 本机直接内存溢出

  直接内存( Direct Memory) 的容量大小可通过-XX: MaxDirectMemorySize参数来指定, 如果不
去指定, 则默认与Java堆最大值( 由-Xmx指定) 一致,越过了DirectByteBuffer类直接通

过反射获取Unsafe实例进行内存分配( Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实
例, 体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能, 在JDK 10时才将Unsafe
的部分功能通过VarHandle开放给外部使用) , 因为虽然使用DirectByteBuffer分配内存也会抛出内存溢
出异常, 但它抛出异常时并没有真正向操作系统申请分配内存, 而是通过计算得知内存无法分配就会
在代码里手动抛出溢出异常, 真正申请分配内存的方法是Unsafe::allocateMemory()。

  由直接内存导致的内存溢出, 一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常
情况, 如果读者发现内存溢出之后产生的Dump文件很小, 而程序中又直接或间接使用了
DirectMemory( 典型的间接使用就是NIO) , 那就可以考虑重点检查一下直接内存方面的原因了。

  

 

posted @ 2020-12-10 00:08  灰辉-灰  阅读(96)  评论(0编辑  收藏  举报