重学《深入理解java虚拟机》のjava内存区域与内存溢出异常

java虚拟机运行时数据区

image.png
  • 程序计数器是当前线程所执行的字节码的行号指示器,线程私有的内存。
  • Java虚拟机栈(Java Virtual Machine Stack) 也是线程私有的, 它的生命周期与线程相同。 虚拟机栈描述的是Java方法执行的线程内存模型: 每个方法被执行的时候, Java虚拟机都会同步创建一个栈帧(Stack Frame) 用于存储局部变量表、 操作数栈、 动态连接、 方法出口等信息。 每一个方法被调用直至执行完毕的过程, 就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • Java堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建。 此内存区域的唯一目的就是存放对象实例,将Java堆细分的目的只是为了更好地回收内存, 或者更快地分配内存。
  • 方法区(Method Area) 与Java堆一样, 是各个线程共享的内存区域, 它用于存储已被虚拟机加载的类型信息、 常量、 静态变量、 即时编译器编译后的代码缓存等数据。
  • 运行时常量池(Runtime Constant Pool) 是方法区的一部分。 Class文件中除了有类的版本、 字段、 方法、 接口等描述信息外, 还有一项信息是常量池表(Constant Pool Table) , 用于存放编译期生成的各种字面量与符号引用, 这部分内容将在类加载后存放到方法区的运行时常量池中。

对象的创建过程

  1. 当Java虚拟机遇到一条字节码new指令时, 首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用, 并且检查这个符号引用代表的类是否已被加载、 解析和初始化过。 如果没有, 那必须先执行相应的类加载过程。
  2. 在类加载检查通过后, 接下来虚拟机将为新生对象分配内存。 对象所需内存的大小在类加载完成后便可完全确定(如何确定将在2.3.2节中介绍) , 为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。 假设Java堆中内存是绝对规整的, 所有被使用过的内存都被放在一边, 空闲的内存被放在另一边, 中间放着一个指针作为分界点的指示器, 那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离, 这种分配方式称为“指针碰撞”(Bump ThePointer) 。 但如果Java堆中的内存并不是规整的, 已被使用的内存和空闲的内存相互交错在一起, 那就没有办法简单地进行指针碰撞了, 虚拟机就必须维护一个列表, 记录上哪些内存块是可用的, 在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录, 这种分配方式称为“空闲列表”(Free List) 。
  3. 内存分配完成之后, 虚拟机必须将分配到的内存空间(但不包括对象头) 都初始化为零值,这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用, 使程序能访问到这些字段的数据类型所对应的值。
  4. Java虚拟机还要对对象进行必要的设置, 例如这个对象是哪个类的实例、 如何才能找到类的元数据信息、 对象的哈希码(实际上对象的哈希码会延后到真正调用bject::hashCode()方法时才计算) 、 对象的GC分代年龄等信息。 这些信息存放在对象的对象头(Object Header) 之中。 根据虚拟机当前运行状态的不同, 如是否启用偏向锁等, 对象头会有不同的设置方式。
  5. 从Java程序的视角看来, 对象创建才刚刚开始——构造函数, 即Class文件中的<init>()方法还没有执行new指令之后会接着执行<init>()方法, 按照程序员的意愿对对象进行初始化, 这样一个真正可用的对象才算完全被构造出来。

对象的内存布局

对象在堆内存的存储布局可以划分为三个部分:对象头、实例数据、对其填充

  • 对象头部分包含两类信息:第一类用于存储对象自身的运行时数据,如哈希吗、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,被称为Mark Word;另一部分存储的是类型指针,即对象指向它的类型元数据的指针;如果对象是一个java数组,那在对象头中还必须有一块用于记录数组长度的数据;
  • 实例数据存储在程序代码里面所定义的各种类型的字段内容, 无论是从父类继承下来的, 还是在子类中定义的字段都必须记录起来。
  • 对其填充仅仅起到占位符的作用,因为HotSpot虚拟机的自动内存管理系统要求对象的大小都必须是8字节的整数倍。

对象的访问定位

  • 通过句柄访问对象:最大好处就是reference中存储的是稳定句柄地址, 在对象被移动(垃圾收集时移动对象是非常普遍的行为) 时只会改变句柄中的实例数据指针, 而reference本身不需要被修改
image.png
  • 通过指针直接访问对象(java使用的方式):最大的好处就是速度更快, 它节省了一次指针定位的时间开销, 由于对象访问在Java中非常频繁, 因此这类开销积少成多也是一项极为可观的执行成本,Hotspot主要使用通过直接指针的方式访问对象,在整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也非常常见。
image.png

String.intern()方法的作用

String::intern()是一个本地方法, 它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串, 则返回代表池中这个字符串的String对象的引用; 否则, 会将此String对象包含的字符串添加到常量池中, 并且返回此String对象的引用。

java堆内存溢出的排查思路

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

  1. 常规的处理方法是首先通过内存映像分析工具(如Eclipse MemoryAnalyzer) 对Dump出来的堆转储快照进行分析。 第一步首先应确认内存中导致OOM的对象是否是必要的, 也就是要先分清楚到底是出现了内存泄漏(Memory Leak) 还是内存溢出(MemoryOverflow) 。
  2. 如果是内存泄漏, 可进一步通过工具查看泄漏对象到GC Roots的引用链, 找到泄漏对象是通过怎样的引用路径、 与哪些GC Roots相关联, 才导致垃圾收集器无法回收它们, 根据泄漏对象的类型信息以及它到GC Roots引用链的信息, 一般可以比较准确地定位到这些对象创建的位置, 进而找出产生内存泄漏的代码的具体位置。
  3. 如果不是内存泄漏, 换句话说就是内存中的对象确实都是必须存活的, 那就应当检查Java虚拟机的堆参数(-Xmx与-Xms) 设置, 与机器的内存对比, 看看是否还有向上调整的空间。 再从代码上检查是否存在某些对象生命周期过长、 持有状态时间过长、 存储结构设计不合理等情况, 尽量减少程序运行期的内存消耗。

直接内存溢出的排查思路

由直接内存导致的内存溢出, 一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况, 如果读者发现内存溢出之后产生的Dump文件很小, 而程序中又直接或间接使用了

DirectMemory(典型的间接使用就是NIO) , 那就可以考虑重点检查一下直接内存方面的原因了。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容