[深入理解Java虚拟机]<自动内存管理>

Overview

  • 走近Java:介绍Java发展史

第二部分:自动内存管理机制

程序员把内存控制的权利交给了Java虚拟机,从而可以在编码时享受自动内存管理。但另一方面一旦出现内存泄漏和溢出等问题,就需要了解一些底层的知识来进行错误排查。

  • 自动内存管理机制:介绍内存是如何划分的。
  • 垃圾收集器与内存分配策略:分析垃圾收集算法。
  • 虚拟机性能监控与故障处理工具
  • 调优案例分析与实战

第三部分:虚拟机执行子系统

  • 类文件系统:介绍Class文件结构的各个组成部分。
  • 虚拟机类加载机制:介绍类加载过程的各个阶段。
  • 虚拟机字节码执行引擎
  • 类加载及执行子系统的案例与实战

第四部分:程序编译与代码优化

Java程序从源码编译成字节码和字节码编译成本地机器码两个过程,加起来就等同于一个传统编译器所执行的编译过程。

  • 早期(编译器)优化:分析泛型、主动拆箱和装箱、条件编译等多种语法糖的前因后果。
  • 晚期(运行期)优化:介绍虚拟机的热点探测方法、HotSpot的即时编译器等等。

第五部分:高效并发

  • Java内存模型与线程
  • 线程安全与锁优化

走近Java

  • Java技术体系:
    • Java程序设计语言
    • 各种硬件平台上的Java虚拟机
    • Class文件格式
    • Java API类库
    • 第三方Java类库

  其中,Java语言 + JVM + API类库 = JDK(Java Development Kit)。JDK是支持Java程序开发的最小环境。

自动内存管理机制

Java内存区域与内存溢出异常

1. 运行时数据区域

JVM在执行Java程序时将其所管理的内存划分为若干个不同的数据区。每个区域有各自的用途,以及创建和销毁时间。

[深入理解Java虚拟机]<自动内存管理>

  • 程序计数器:一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等都需要依赖该计数器。
    • JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时间,一个处理器只会执行一条线程中的指令。因此,为了在线程切换时恢复到正确的执行位置,每个线程都需要一个独立的程序计数器。
    • 若线程在执行一个Java方法,则计数器纪录的是正在执行的虚拟机字节码指令的地址;若执行的是native方法,则计数器值为空。
    • 该内存区域是唯一一个未规定任何OutOfMemoryError情况的区域。
  • Java虚拟机栈:
    • 与程序计数器一样,也是线程私有的。
    • JVM栈描述的是Java方法执行的内存模型。每个方法在执行的同时会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
    • 每个方法从调用到执行完成的过程,就对应一个栈帧在JVM栈中入栈到出栈的过程。
    • 很多时候,我们经常会讲Java内存区分为Heap和Stack,这种分法比较粗糙,这里所指的栈其实就是上面介绍的局部变量表,因为这是程序员最关注的。
    • 局部变量表存放了编译器可知的各种基础数据类型、对象引用(注意这不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置。)以及returnAddress类型。
    • 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,该方法所需在帧中分配的局部变量空间时完全确定的,在方法运行期间不会改变局部变量表的大小。
    • 在JVM规范中,对该区域规定了两种异常情况:
      • 若线程请求的栈深度大于虚拟机所运行的深度 --> *Error异常;
      • 若JVM栈可以动态扩展(当前大部分JVM都可以动态扩展,只不过JVM规范中也运行固定长度的JVM栈),若扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
  • 本地方法栈:与JVM栈作用十分类似,主要区别在于JVM栈未JVM执行Java方法(也就是字节码)服务,而本地方法栈则为JVM所使用到的Native方法服务。
  • Java堆:对大多数应用而言,heap时JVM所管理的内存中最大的一块。
    • heap对所有线程共享,在JVM启动时创建。
    • 此内存区域的唯一目的就是存放对象实例,几乎所有对象都在这里分配内存。
    • heap是垃圾收集器管理的主要区域。从内存回收的角度来看,由于现在的收集器基本都采用分代收集算法,所以Java堆还可以细分为新生代和老年代。
    • 根据JVM规范,heap可以处理物理上不连续的内存空间,只要逻辑上是连续的即可。可以是固定大小的,也可以是可扩展的。
    • 若堆中没有内存完成实例分配,并且堆也无法再扩展时,会抛OutOfMemoryError异常。
  • 方法区:同样是多个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
    • 虽然JVM规范把方法区描述为堆的一个逻辑部分,但它却有一个别名叫做Non-heap。
    • JVM对方法区的限制非常宽松,除了和heap一样不需要连续内存和可选固定大小或可扩展外,还可以选择不实现垃圾收集。
    • 该区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
    • 可能抛OutOfMemoryError异常。
  • 运行时常量池:是方法区的一部分。
    • Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池存放。
    • 运行时常量池相对class文件常量池的一个重要特征是具备动态性。即可以在运行期间将新的常量放入池中。最常见的就是String类的intern()方法。
  • 直接内存
    • 直接内存并不是JVM运行时数据区的一部分,也不是JVM规范中定义的内存区域。
    • 但这部分内存也被频繁地使用,也可能导致OutOfMemoryError。
    • NIO类:引入了一种基于通道(Channel)与缓存区(Buffer)的I/O方法,可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。(这样能显著提高性能,因为避免了在Java堆和Native堆之间来回复制数据。)
    • 总之,直接内存的分配不受到Java堆大小的限制,但仍然受到本机总内存的限制,仍可能抛OutOfMemoryError。

2. HotSpot虚拟机对象探秘

  1. 对象的创建:虚拟机在遇到一条new指令时:
    1. [类加载检查]:首先检查该指令的参数是否能在常量池中定位到一个类的符号引用,并且检查该符号引用代表的类是否已被加载、解析和初始化过。
    2. [为新生对象分配内存]:对象所需内存的大小在类加载完成后便可完全确定(后续会介绍如何确定)。为对象分配内存,等同于把一块确定大小的内存从Java堆中划分出来(如果Java堆中内存是绝对规整的,那么只需要简单地维持一个指针作为分界点指示器,这种方式称为“指针碰撞”。如果内存不是规整的,那JVM必须维护一个列表,纪录哪些内存块是可用的,这种方式称为“空闲列表”。heap是否规整由垃圾收集器是否带有压缩整理功能决定。)。
    3. [线程安全问题]:对象创建载JVM中是非常频繁的,即使仅仅是修改一个指针所指向的位置,在并发情况下也不是线程安全的。解决方案:1)对分配内存空间的动作进行同步处理,保证更新操作的原子性;2)把内存分配动作按线程划分在不同的空间之中进行。
    4. [初始化]:内存分配完成后,JVM需要将分配到的空间都初始化为零(不包括对象头)。
    5. [对对象进行必要的设置]:例如该对象是哪个类的实例,如何才能找到类的元数据信息、对象的hash码、对象的GC分代年龄等信息。这些信息存放在对象头(Object header)之中。
  2. 对象的内存布局:在HotSpot虚拟机中,对象在内存中的布局可以分为3块区域:对象头、实例数据和对齐填充。
    1. 对象头:一部分用于存储对象自身的运行时数据,如hashCode、GC分代年龄、锁状态标志、线程持有的锁等;另一部分是类型指针,即对象指向它的类元数据的指针;另外如果对象是Java数组,那么对象头中还必须有一块用于纪录数组长度的数据,因为JVM可以通过普通Java对象的元数据信息确定Java对象的大小,但从数组的元数据中无法确定数组的大小。
    2. 实例数据:对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容。这部分的存储顺序会受到JVM分配策略参数和字段在Java源码中的定义顺序的影响。HotSpot默认分配策略为longs/doubles, ints, shorts/chars, bytes/booleans, oops(Ordinary Object Pointers) [可以看出相同宽度的字段总是被分配到一起],在类型相同的情况下会按照定义顺序排。
    3. 对齐填充:该部分是非必然存在的,它仅仅起着占位符的作用。因为HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍。
  3. 对象的访问定位:Java程序需要通过栈上的reference数据来操作堆上的具体对象。对象访问方式取决于JVM,主流的有使用句柄和直接指针指针两种。
    • 句柄访问:heap中将会划分出一块内存来作为句柄池,reference中存的就是对象的句柄地址。好处是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象)时只改变实例数据指针。
      [深入理解Java虚拟机]<自动内存管理>
    • 直接指针:reference中存储的直接就是对象的地址。那么heap对象的布局中就必须考虑如何放置访问类型数据的相关信息。好处是速度更快,节省了一次指针定位的时间开销。
      [深入理解Java虚拟机]<自动内存管理>

3. 实战:OutOfMemoryError异常

在JVM规范中,除了程序计数器外,虚拟机内存的其他几个运行时区域都可能OOM。

  1. Java堆溢出
    Java堆用于存储对象实例,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,就会产生OOM。
    • 首先要判断是内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
    • 若是内存泄漏:可使用工具查询泄漏对象到GC Roots的引用链。
    • 若是内存溢出:从代码上检查是否存在某些对象生命周期过长、持有时间过长的情况,尝试减少程序运行期的内存消耗。
  2. 虚拟机栈和本地方法栈溢出
    HotSpot中不区分虚拟机栈和本地方法栈。
    1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,抛*。
    2. 若虚拟机在扩展栈时无法申请到足够的内存空间,抛OOM。
  3. 方法区和运行时常量池溢出
    • 一段常量池OOM的代码:
      public class RuntimeConstantPoolOOM {
      public static void main(String[] args) {
      // use List to keep the reference of Constants, avoiding GC
      List<String> list = new ArrayList<>();
      int i = 0;
      while (true) {
      list.add(String.valueOf(i++).intern());
      }
      }
      }
  4. 本机直接内存溢出
    DirectMemory容量默认与heap最大值一样。
上一篇:[SAA + SAP] 09. DynamoDB


下一篇:SSM9.1【Spring:面向切面编程AOP-Spring的AOP简介】