Java虚拟机内存结构介绍

一 前言

  最近项目开始了各种频繁的测试,各组工作也日渐紧张,联机交易及日终批量在高压力测试下,出现了越来越多的内存溢出问题。

  其实这里说内存溢出并不严谨,因为我们没有办法很直观的判断出究竟是溢出还是泄漏。今天又有同事问我:为什么会出现OOM?这个问题说起来简单也不简单,如果要就某一个场景来说,需要很客观的分析才能下定论;但如果从理论上来阐述,又不是一件很困的事情,所以今天我就OOM问题对JVM内存结构做一个很简单的介绍,希望能对一些朋友有所帮助。

  关于JVM内存结构更多的介绍,可以查阅官方发布的JVM规范,当然如果对这一块知识没那么迫切的需求,在网络上或者其他书籍中获取一些线索也是不错的。

二 JVM内存结构

  关于OOM的定义,在JVM规范中有很明确的说明,它的发生位置又和具体的内存位置紧密相关,所以在了解OOM的时候,首要的前提是摸清楚JVM的内存结构。

  简而言之,OOM发生的条件很明确——JVM不足以申请到足够的内存。那么哪些内存结构会频繁的申请内存,申请到的内存又被用来做了什么事情,是否会发生OOM,这些内容我会按每一块的内存结构进行简单的介绍。

  先行说明下JVM的结构,按JVM规范,其内存结构大致分为以下几部分:

  1. 程序计数器
  2. 虚拟机栈
  3. 本地方法栈
  4. GC堆
  5. 方法区
  6. 运行时常量区

  后文的对于每一块内存区域的介绍,我会从以下几点进行描述:

  1. 内存区域的用途
  2. 内存数据访问的限制
  3. 是否会发生OOM,何时发生

三 程序计数器

  Program Counter Register,它占用了JVM非常小的一块内存,小到可以忽略不计。然而其作用异常的关键,JVM是无法执行Java源文件的,它只认Class文件(并非只有Java源文件才能编译出来Class文件,也就是说Java虚拟机并非只能执行Java语言编写的程序,可别把两者绑死),然而Class也就是字节码文件的执行是需要字节码解释器来处理的,字节码解释器在工作的时候就是通过改变程序计数器的值来选取下一条要执行的字节码指令的。

  我们常常编写的程序流程,包括分支(if,else)、跳转(break等)、循环(for,while等)、异常处理(try,catch等)以及线程恢复这些功能的实现全部依赖于程序计数器。你可以把它当作乐队指挥,何时吹拉弹唱它说了算。

  Java是支持多线程的,那么JVM在处理线程切换的时候,如何能够保证每条线程的处理位置是正确的呢?答案也在程序计数器中,它保存了每条线程的已执行到的位置,换言之每个线程都有一个独立的程序计数器,各线程的程序计数器间不会互相影响,数据独立存储,我们将这种内存访问方式称之为“线程私有”。

  最后,因为程序计数器占用的内存实在是太小了,所以JVM规范中没有给它定义任何OOM,这是JVM中唯一一个没有OOM的内存区域。

四 虚拟机栈

  Java Virtual Machine Stacks,学习Java的时候,我们常常提到堆栈,其中栈指的就是Java虚拟机栈,它用来描述Java方法在执行时候的内存模型,每一个方法在被调用的时候都会在栈中开辟一块新的内存区域,这部分区域我们称之为栈帧(Stack Frame),栈帧中存储着方法局部变量、操作数、动态链接以及返回值等数据,每一个方法从调用到结束就是一个栈帧进入Java虚拟机栈到出栈的过程。

  栈中的数据是不能被多线程共享的,也就是说每个Java对象的方法其方法内声明的变量、运算逻辑等生命周期仅在方法内部,在并发编程知识概念中,我们称之为线程封闭。所以说虚拟机栈的数据访问是线程私有的。

  因为每一个栈帧的大小是不一样的,这里不是很严谨,应该说大部分情况下是不一样的,Java方法的逻辑深度决定了栈帧的大小,这里说的大小指的是其申请到的栈内存,因为压入栈中的数据越多,其占用的内存也越大。换句话说,栈的深度和内存消耗是有着直接联系的,所以JVM对虚拟机栈规定了两种内存相关的异常:

  1. StackOverflowError
  2. OutOfMemoryError

  注意,栈溢出仅在线程请求的栈深度大于JVM所允许的深度值时才会发生,而OOM发生在栈内存不足时发生,上面我提到过栈的深度和内存占用是有直接关系的,所以本质上来说它们是对内存不足的不同描述而已。JVM优化时可以对线程分配的内存大小进行控制。这部分以后有机会再说。

五 本地方法栈

  Native Method Stack,在Java语法体系中,除JDK提供的由Java编写的API之外还有一部分本地方法集,它用于与操作系统进行交互。JVM内存结构中的两大栈,VirtualStack和NativeStack,除了用途不一样,其他的说明差不多,也会出现栈溢出和OOM。

六 GC堆

  Java Heap,常规堆栈说法中的另一大主要内存区域——Java堆,我管它叫GC堆(垃圾堆),不仅因为这个名字好玩,也因为这样描述十分形象,Java Heap是垃圾回收的主要战场。

  GC堆可以说是JVM中占用内存最大的一块,其中存放了所有对象实例以及数组,哪怕方法中声明的引用对象,其栈中也只是存放了其对象的引用地址,对象的内存分配依然在堆上,这部分内存区域较为复杂,做JVM调优的时候尤为重要。

  因为所有的对象及数组的内存分配均在堆上,所以这部分的数据访问是线程共享的。

  堆的大小受JVM参数控制,当然也跟具体的操作系统有关,比如说32位的windows,即使我给堆分配了最大10G的内存,系统对进程的限制也只有2G,又有什么用呢?随着堆中的数据扩展,其内存消耗越来越大,当无法再申请到足够内存的时候,就会抛出OOM。

七 方法区

  Method Area,上文中提到的很多和内存相关的介绍都是针对对象级别的,而方法区不一样,它存放JVM加载过的类、常量、静态变量或者即时编译器编译后的代码数据。

  想想看方法区中存放的数据就知道这部分数据一定是线程共享的。

  这部分数据随着类加载的越来越多,内存消耗也会变得越来越大,一些开源框架尤其喜欢动态创建类型(各种动态代理),对方法区的内存压力变得更大,当内存分配不足时就会抛出OOM。

八 运行时常量池

  Runtime Constant Pool,本质上它属于方法区,单拎出来是因为它存放的数据更为细致,方法区中除了存放有类的相关信息,如成员、方法或者接口等,还有各种字面量和符号引用,这部分数据就存放在运行时常量池,这部分信息可以参考String.intern()方法。

  既然属于方法区那么必然是线程共享的。

  既然属于方法区那么必然也会出现OOM。

九 直接内存

  Direct Memory,其实这部分并未在JVM规范中定义的,但是它的确存在,不知道你们有没有编写过NIO,至少我现在参与的项目,其通信数据传输是NIO实现的,NIO使用的就是直接内存——更为直观的说,就是JVM外的本地内存。

  既然是内存,那么必然受系统内存的限制,除了JVM分配后的内存,以及其他进程使用的内存,剩下才能给直接内存使用,剩的少要的多,就会出现OOM。

十 总结

  综上,JVM的内存结构大致就是这样,唯一一个没有OOM的就是程序计数器,其他内存区域都会有OOM的可能,那么如何避免OOM就涉及到了更为复杂层面,不单单要求Java开发人员所编写的程序是健壮的,它还对系统配置有所要求。

  对于分析OOM问题,已知的手段已经非常多了,这涉及到更为细致的内存分析,包括GC频次,GC位置等等,后面如果有时间我会对GC以及内存优化方面做一些笔记。

  我不是很建议去读官方的JVM规范,首先它对于每一个Java程序员来说有些过于遥远,而且长篇累牍,不如平时多积累些相关方面的知识,从书本也好,从网络上也好,当然最直观的比如说我自己,身处一个庞大的项目中,你不得不去面对,这才是提升最快的。

猜你喜欢

转载自blog.csdn.net/o983950935/article/details/85849606