Java 应用程序是运行在 JVM 上的, 得益于 JVM 的内存管理和垃圾收集机制, 开发人员的效率得到了显著提升, 也不容易出现内存溢出和泄漏问题. 但正是因为开发人员把内存的控制权交给了 JVM, 一旦出现内存方面的问题, 如果不了解 JVM 的工作原理, 将很难排查错误. 本文将从理论角度介绍虚拟机的内存管理和垃圾回收机制, 算是入门级的文章, 希望对大家的日常开发有所助益.
一, 内存管理
也许大家都有过这样的经历, 在启动时通过 - Xmx 或者 - XX:MaxPermSize 这样的参数来显式的设置应用的堆 (Heap) 和永久代 (Permgen) 的内存大小, 但为什么不直接设置 JVM 所占内存的大小, 而要分别去设置不同的区域? JVM 所管理的内存被分成多少区域? 每个区域有什么作用? 如何来管理这些区域?
1.1 运行时数据区
JVM 在执行 Java 程序时会把其所管理的内存划分成多个不同的数据区域, 每个区域的创建时间, 销毁时间以及用途都各不相同. 比如有的内存区域是所有线程共享的, 而有的内存区域是线程隔离的. 线程隔离的区域就会随着线程的启动和结束而创建和销毁. JVM 所管理的内存将会包含以下几个运行时数据区域, 如下图的上半部分所示.
image
Method Area (方法区)
方法区是所有线程共享的内存区域, 它用于存储已被虚拟机加载的类信息, 常量, 静态变量, JIT 编译后的代码等数据. 在 Java 虚拟机规范中, 方法区属于堆的一个逻辑部分, 但很多情况下, 都把方法区与堆区分开来说. 大家平时开发中通过反射获取到的类名, 方法名, 字段名称, 访问修饰符等信息都是从这块区域获取的.
对于 HotSpot 虚拟机, 方法区对应为永久代 (Permanent Generation), 但本质上, 两者并不等价, 仅仅是因为 HotSpot 虚拟机的设计团队是用永久代来实现方法区而已, 对于其他的虚拟机(JRockit,J9) 来说, 是不存在永久代这一概念的.
但现在看来, 使用永久代来实现方法区并不是一个好注意, 由于方法区会存放 Class 的相关信息, 如类名, 访问修饰符, 常量池, 字段描述, 方法描述等, 在某些场景下非常容易出现永久代内存溢出. 如 Spring,Hibernate 等框架在对类进行增强时, 都会使用到 CGLib 这类字节码技术, 增强的类越多, 就需要越大的方法区来保证动态生成的 Class 可以加载入内存. 在 JSP 页面较多的情况下, 也会出现同样的问题. 可以通过如下代码来测试:
- /**
- * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M(JDK6.0)
- * VM Args: -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M(JDK8.0)
- */
- public class CGlibProxy {
- public static void main(String[] args) {
- while (true) {
- Enhancer enhancer = new Enhancer();
- enhancer.setSuperclass(ProxyObject.class);
- enhancer.setUseCache(false);
- enhancer.setCallback(new MethodInterceptor() {
- @Override
- public Object intercept(Object o, Method method, Object[] os, MethodProxy proxy) throws Throwable {
- System.out.println("I am proxy");
- return proxy.invokeSuper(o,os);
- }
- });
- ProxyObject proxy = (ProxyObject) enhancer.create();
- proxy.greet();
- }
- }
- static class ProxyObject {
- public String greet() {
- return "Thanks for you";
- }
- }
- }
在 JDK1.8 中运行一小会儿出现内存溢出错误:
- Exception in thread "main" I am proxy
- java.lang.OutOfMemoryError: Metaspace
- at org.mockito.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:238)
- at org.mockito.cglib.proxy.Enhancer.createHelper(Enhancer.java:378)
- at org.mockito.cglib.proxy.Enhancer.create(Enhancer.java:286)
- at com.lwork.mdo.CGlibProxy.main(CGlibProxy.java:23)
在 JDK1.8 下并没有出现我们期望的永久代内存溢出错误, 而是 Metaspace 内存溢出错误. 这是因为 Java 团队从 JDK1.7 开始就逐渐移除了永久代, 到 JDK1.8 时, 永久代已经被 Metaspace 取代, 因此在 JDK1.8 并没有出现我们期望的永久代内存溢出错误. 在 JDK1.8 中, JVM 参数 - XX:PermSize 和 - XX:MaxPermSize 已经失效, 取而代之的是 - XX:MetaspaceSize 和 XX:MaxMetaspaceSize. 注意: Metaspace 已经不再使用堆空间, 转而使用 Native Memory. 关于 Native Memory, 下文会详细说明.
还有一点需要说明的是, 在 JDK1.6 中, 方法区虽然被称为永久代, 但并不意味着这些对象真的能够永久存在了, JVM 的内存回收机制, 仍然会对这一块区域进行扫描, 即使回收这部分内存的条件相当苛刻.
Runtime Constant Pool (运行时常量池)
回过头来看下图 1 的下半部分, 方法区主要包含:
运行时常量池 (Runtime Constant Pool) 类信息(Class & Field & Method data) 编译器编译后的代码(Code) 等等 后面两项都比较好理解, 但运行时常量池有何作用, 其意义何在? 抛开运行时 3 个字, 首先了解下何为常量池. Java 源文件经编译后得到存储字节码的 Class 文件, Class 文件是一组以 8 位字节为基础单位的二进制流, 各个数据项目严格按照顺序紧凑地排列在 Class 文件中. 也就是说, 哪个字节代表什么含义, 长度多少, 先后顺序如何都是被严格限定的, 是不允许改变的. 比如: 开头的 4 个字节存放在魔数, 用于确定这个文件是否能够被 JVM 接受, 接下来的 4 个字节用于存放版本号, 再接着存放的就是常量池, 常量池的长度是不固定的, 所以, 在常量池的入口存放着常量池容量的计数值.
常量池主要用于存放两大类常量: 字面量和符号引用量, 字面量相当于 Java 语言层面常量的概念, 比如: 字符串常量, 声明为 final 的常量等等. 符号引用是用一组符号来描述所引用的目标, 符号可以是任何形式的字面量, 只要使用时能无歧义的定位到目标即可. 理解不了? 举个例子, 有如下代码:
- public class M {
- private int m;
- private String mstring = "chen";
- public void f() {
- }
- }
使用 javap 工具输出 M.class 文件字节码的部分内容如下:
- javap -verbose M
- ......
- Constant pool:
- #1 = Methodref #5.#20 // java/lang/Object."<init>":()V
- #2 = String #21 // chen
- #3 = Fieldref #4.#22 // com/lwork/mdo/M.mstring:Ljava/lang/String;
- #4 = Class #23 // com/lwork/mdo/M
- #5 = Class #24 // java/lang/Object
- #6 = Utf8 m
- #7 = Utf8 I
- #8 = Utf8 mstring
- #9 = Utf8 Ljava/lang/String;
- #10 = Utf8 <init>
- #11 = Utf8 ()V
- #12 = Utf8 Code
- #13 = Utf8 LineNumberTable
- #14 = Utf8 LocalVariableTable
- #15 = Utf8 this
- #16 = Utf8 Lcom/lwork/mdo/M;
- // 方法名称
- #17 = Utf8 f
- #18 = Utf8 SourceFile
- // 类名称
- #19 = Utf8 M.java
- #20 = NameAndType #10:#11 // "<init>":()V
- #21 = Utf8 chen
- #22 = NameAndType #8:#9 // mstring:Ljava/lang/String;
- // 类的完整路径, 注意 class 文件中是用 "/" 来代替 "."
- #23 = Utf8 com/lwork/mdo/M
- #24 = Utf8 java/lang/Object
- ......
这里只保留了常量池的部分, 从中可以看到 M.class 文件的常量池总共 24 项, 其中包含类的完整名称, 字段名称和描述符, 方法名称和描述符等等. 当然其中还包含 I,V,,LineNumberTable,LocalVariableTable 等代码中没有出现过的常量, 其实这些常量是用来描述如下信息: 方法的返回值是什么? 有多少个参数? 每个参数的类型是什么...... 这个示例非常直观的向大家展示了常量池中存储的内容.
接下来就比较好理解运行时常量池了. 我们都知道: Class 文件中存储的各种信息, 最终都需要加载到虚拟机中之后才能运行和使用. 运行时常量池就可以理解为常量池被加载到内存之后的版本, 但并非只有 Class 文件中常量池的内容才能进入方法区的运行时常量池, 运行期间也可能产生新的常量, 它们也可以放入运行时常量池中.
Heap Space (Java 堆)
Java 堆是 JVM 所管理的最大一块内存, 所有线程共享这块内存区域, 几乎所有的对象实例都在这里分配内存, 因此, 它也是垃圾收集器管理的主要区域. 从内存回收的角度来看, 由于现在的收集器基本都采用分代收集算法, 所以 Java 堆又可以细分成: 新生代和老年代, 新生代里面有分为: Eden 空间, From Survivor 空间, To Survivor 空间, 如图 1 所示. 有一点需要注意: Java 堆空间只是在逻辑上是连续的, 在物理上并不一定是连续的内存空间.
默认情况下, 新生代中 Eden 空间与 Survivor 空间的比例是 8:1, 注意不要被示意图误导, 可以使用参数 - XX:SurvivorRatio 对其进行配置. 大多数情况下, 新生对象在新生代 Eden 区中分配, 当 Eden 区没有足够的空间进行分配时, 则触发一次 Minor GC, 将对象 Copy 到 Survivor 区, 如果 Survivor 区没有足够的空间来容纳, 则会通过分配担保机制提前转移到老年代去.
何为分配担保机制? 在发送 Minor GC 前, JVM 会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间, 如果是, 那么可以确保 Minor GC 是安全的, 如果不是, 那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果小于, 直接进行 Full GC, 如果大于, 将尝试着进行一次 Minor GC,Minor GC 失败才会触发 Full GC. 注: 不同版本的 JDK, 流程略有不同
Survivor 区作为 Eden 区和老年代的缓冲区域, 常规情况下, 在 Survivor 区的对象经过若干次垃圾回收仍然存活的话, 才会被转移到老年代. JVM 通过这种方式, 将大部分命短的对象放在一起, 将少数命长的对象放在一起, 分别采取不同的回收策略. 关于 JVM 内存分配更直观的介绍, 请阅读参考资料 3.
VM Stack (虚拟机栈) & Native Method Stack (本地方法栈)
虚拟机栈与本地方法栈都属于线程私有, 它们的生命周期与线程相同. 虚拟机栈用于描述 Java 方法执行的内存模型: 每个方法在执行的同时都会创建一个栈帧 (Stack Frame) 用于存储局部变量表, 操作数栈, 动态连接, 方法出口等信息.
其中局部变量表用于存储方法参数和方法内部定义的局部变量, 它只在当前函数调用中有效, 当函数调用结束, 随着函数栈帧的销毁, 局部变量表也随之消失; 操作数栈是一个后入先出栈, 用于存放方法运行过程中的各种中间变量和字节码指令 (在学习栈的时候, 有一个经典的例子就是用栈来实现 4 则运算, 其实方法执行过程中操作数栈的变化过程, 与 4 则预算中栈中数字与符号的变化类似); 动态连接其实是指一个过程, 即在程序运行过程中将符号引用解析为直接引用的过程.
如何理解动态连接? 我们知道 Class 文件的常量池中存有大量的符号引用, 在加载过程中会被原样的拷贝到内存里先放着, 到真正使用的时候就会被解析为直接引用 (直接引用包含: 直接指向目标的指针, 相对偏移量, 能间接定位到目标的句柄等). 有些符号引用会在类的加载阶段或者第一次使用的时候转化为直接引用, 这种转化称为静态解析, 而有的将在运行期间转化为直接引用, 这部分称为动态连接.
全部静态解析不是更好, 为何会存在动态连接? Java 多态的实现会导致一个引用变量到底指向哪个类的实例对象, 或者说该引用变量发出的方法调用到底是调用哪个类中实现方法都需要在运行期间才能确定. 因此有些符号引用在类加载阶段是不知道它对应的直接引用的
每一个方法从调用直至执行完成的过程, 就对应着一个栈帧在虚拟机栈中入栈到出栈的过程, 下面通过一个非常简单的图例来描述这一过程, 有如下的代码片段:
- public void sayHello(String name) {
- System.out.println("hello" + name);
- greet(name);
- bye();
- }
其调用过程中虚拟机栈的大致示意图如下图所示:
image
调用 sayHello 方法时, 在栈中分配有一块内存用来保存该方法的局部变量等信息,1当函数执行到 greet()方法时, 栈中同样有一块内存用来保存 greet 方法的相关信息, 当然第二个内存块位于第一个内存块上面,2接着从 greet 方法返回,3现在栈顶的内存块就是 sayHello 方法的, 这表示你已经返回到 sayHello 方法,4接着继续调用 bye 方法, 在栈顶添加了 bye 方法的内存块,5接着再从 bye 方法返回到 sayHello 方法中, 由于没有别的事了, 现在就从 sayHello 方法返回.
本地方法栈与虚拟机栈所发挥的作用是非常相似的, 它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法 (也就是字节码) 服务, 而本地方法栈则为虚拟机使用到的 Native 方法服务.
Program Counter Register (程序计数器)
程序计数器(Program Counter Register), 很多地方也被称为 PC 寄存器, 但寄存器是 CPU 的一个部件, 用于存储 CPU 内部重要的数据资源, 比如在汇编语言中, 它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址), 当 CPU 需要执行指令时, 需要从程序计数器中得到当前需要执行的指令所在存储单元的地址, 然后根据得到的地址获取到指令, 在得到指令之后, 程序计数器便自动加 1 或者根据转移指针得到下一条指令的地址, 如此循环, 直至执行完所有的指令.
类似的, JVM 规范中规定, 如果线程执行的是非 native 方法, 则程序计数器中保存的是当前需要执行的指令的地址; 如果线程执行的是 native 方法, 则程序计数器中的值是 undefined.
Java 虚拟机可以支持多条线程同时执行, 多线程是通过线程轮流切换来获得 CPU 执行时间的, 因此, 在任一具体时刻, 一个 CPU 的内核只会执行一条线程中的指令, 因此, 为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置, 每个线程都需要有自己独立的程序计数器, 并且不能互相被干扰, 否则就会影响到程序的正常执行次序. 因此, JVM 中的程序计数器是每个线程私有的.
1.2 堆外内存
堆外内存又被称为直接内存(Direct Memory), 它并不是虚拟机运行时数据区的一部分, Java 虚拟机规范中也没有定义这部分内存区域, 使用时由 Java 程序直接向系统申请, 访问直接内存的速度要优于 Java 堆, 因此, 读写频繁的场景下使用直接内存, 性能会有提升, 比如 Java NIO 库, 就是使用 Native 函数直接分配堆外内存, 然后通过一个存储在 Java 堆中的 DirectBytedBuffer 对象作为这块内存的引用进行操作.
由于直接内存在 Java 堆外, 其大小不会直接受限于 Xmx 指定的堆大小, 但它肯定会受到本机总内存大小以及处理器寻址空间的限制, 因此我们在配置 JVM 参数时, 特别是有大量网络通讯场景下, 要特别注意, 防止各个内存区域的总内存大于物理内存限制 (包括物理的和 OS 的限制).
1.3 小结
花了很大篇幅来介绍 Java 虚拟机的内存结构, 其中在讲解 Java 堆时, 还简单的介绍了 JVM 的内存分配机制; 在介绍虚拟机栈的同时, 也对方法调用过程中栈的数据变化作了形象的说明. 当然这样的篇幅肯定不足以完全理清整个内存结构以及其内存分配机制, 你尽可以把它当做简单的入门, 带你更好的学习. 接下来会以此为背景介绍一些常用的 JVM 参数.
二, 常用 JVM 参数
2.1 关于 JVM 参数必须知道的小知识
JVM 参数分为标准参数和非标准参数, 所有以 - X 和 - XX 开头的参数都是非标准参数, 标准参数可以通过 java -help 命令查看, 比如:-server 就是一个标准参数.
非标准参数中, 以 - XX 开头的都是不稳定的且不推荐在生成环境中使用. 但现在的情况已经有所改变, 很多 - XX 开头的参数也已经非常稳定了, 但不管什么参数在使用前都应该了解它可能产生的影响.
布尔型参数,-XX:+ 表示激活选项,-XX:- 表示关闭此选项. 部分参数可以使用 jinfo 工具动态设置, 比如: jinfo -flag +PrintGCDetails 12278, 能够动态设置的参数很少, 所以用处有限, 至于哪些参数可以动态设置, 可以参考 jinfo 工具的使用方法.
2.2 GC 日志
- 2018-01-07T19:45:08.627+0800: 0.794: [GC (Allocation Failure) [PSYoungGen: 153600K->4564K(179200K)] 153600K->4580K(384000K), 0.0051736 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
- ......
来源: http://www.jianshu.com/p/b1cf2e3bf5b6