×

详解JVM内存管理与垃圾回收机制1 - 内存管理

96
CHEN川
2018.03.15 17:02* 字数 6269

Java应用程序是运行在JVM上的,得益于JVM的内存管理和垃圾收集机制,开发人员的效率得到了显著提升,也不容易出现内存溢出和泄漏问题。但正是因为开发人员把内存的控制权交给了JVM,一旦出现内存方面的问题,如果不了解JVM的工作原理,将很难排查错误。本文将从理论角度介绍虚拟机的内存管理和垃圾回收机制,算是入门级的文章,希望对大家的日常开发有所助益。

一、内存管理

也许大家都有过这样的经历,在启动时通过-Xmx或者-XX:MaxPermSize这样的参数来显式的设置应用的堆(Heap)和永久代(Permgen)的内存大小,但为什么不直接设置JVM所占内存的大小,而要分别去设置不同的区域?JVM所管理的内存被分成多少区域?每个区域有什么作用?如何来管理这些区域?

1.1 运行时数据区

JVM在执行Java程序时会把其所管理的内存划分成多个不同的数据区域,每个区域的创建时间、销毁时间以及用途都各不相同。比如有的内存区域是所有线程共享的,而有的内存区域是线程隔离的。线程隔离的区域就会随着线程的启动和结束而创建和销毁。JVM所管理的内存将会包含以下几个运行时数据区域,如下图的上半部分所示。


图1:JVM运行时数据区

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:MetaspaceSizeXX:MaxMetaspaceSize。注意:Metaspace已经不再使用堆空间,转而使用Native Memory。关于Native Memory,下文会详细说明。

还有一点需要说明的是,在JDK1.6中,方法区虽然被称为永久代,但并不意味着这些对象真的能够永久存在了,JVM的内存回收机制,仍然会对这一块区域进行扫描,即使回收这部分内存的条件相当苛刻。

Runtime Constant Pool (运行时常量池)

回过头来看下图1的下半部分,方法区主要包含:

  1. 运行时常量池(Runtime Constant Pool)
  2. 类信息(Class & Field & Method data)
  3. 编译器编译后的代码(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项,其中包含类的完整名称、字段名称和描述符、方法名称和描述符等等。当然其中还包含IV<init>LineNumberTableLocalVariableTable等代码中没有出现过的常量,其实这些常量是用来描述如下信息:方法的返回值是什么?有多少个参数?每个参数的类型是什么…… 这个示例非常直观的向大家展示了常量池中存储的内容。

接下来就比较好理解运行时常量池了。我们都知道: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();
}

其调用过程中虚拟机栈的大致示意图如下图所示:


图二:调用栈

调用sayHello方法时,在栈中分配有一块内存用来保存该方法的局部变量等信息,①当函数执行到greet()方法时,栈中同样有一块内存用来保存greet方法的相关信息,当然第二个内存块位于第一个内存块上面,②接着从greet方法返回,③现在栈顶的内存块就是sayHello方法的,这表示你已经返回到sayHello方法,④接着继续调用bye方法,在栈顶添加了bye方法的内存块,⑤接着再从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参数必须知道的小知识

  1. JVM参数分为标准参数和非标准参数,所有以-X-XX开头的参数都是非标准参数,标准参数可以通过java -help命令查看,比如:-server就是一个标准参数。
  2. 非标准参数中,以-XX开头的都是不稳定的且不推荐在生成环境中使用。但现在的情况已经有所改变,很多-XX开头的参数也已经非常稳定了,但不管什么参数在使用前都应该了解它可能产生的影响。
  3. 布尔型参数,-XX:+表示激活选项,-XX:-表示关闭此选项。
  4. 部分参数可以使用jinfo工具动态设置,比如:jinfo -flag +PrintGCDetails 12278,能够动态设置的参数很少,所以用处有限,至于哪些参数可以动态设置,可以参考jinfo工具的使用方法。

2.2 GC日志

GC日志是一个非常重要的工具,它准确的记录了每一次GC的执行时间和结果,通过分析GC日志可以帮助我们优化内存设置,也可以帮助改进应用的对象分配方式。如何阅读GC日志不在本文的范畴内,大家可以参考网上相关文章。

下面几个关于GC日志的参数应该加入到应用启动参数列表中:

  • -XX:+PrintGCDetails 开启详细GC日志模式
  • -XX:+PrintGCTimeStamps在每行GC日志头部加上GC发生的时间,这个时间是指相对于JVM的启动时间,单位是秒
  • -XX:+PrintGCDateStamps在GC日志的每一行加上绝对日期和时间,推荐同时使用这两个参数,这样在关联不同来源的GC日志时很有帮助
  • -XX:+PrintHeapAtGC输出GC回收前和回收后的堆信息,使用这个参数可以更好的观察GC对堆空间的影响
  • -Xloggc设置GC日志目录

设置这几个参数后,发生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]
......

简单的说明:

  • 2018-01-07T19:45:08.627+0800 - GC开始时间
  • 0.794 - GC开始时间相对于JVM启动时间
  • GC - 用来区分是Minor GC 还是 Full GC,这里是Minor GC
  • Allocation Failure - GC原因,这里是因为年轻代中没有任何足够空间,也就是分配失败
  • PSYoungGen - 垃圾收集算法,这里是Parallel Scavenge
  • 153600K->4564K(179200K) - 本次垃圾回收前后年轻代内存使用情况,括号内表示年轻代总大小
  • 153600K->4580K(384000K) - 在本次垃圾回收前后整个堆内存的使用情况,括号内表示总的可用堆内存
  • 0.0051736 secs - GC持续时间
  • [Times: user=0.01 sys=0.00, real=0.01 secs] - 多个维度衡量GC持续时间

2.3 内存优化

我们的程序可能会经常出现性能问题,但如何分析和定位?知道一些常用的JVM内存管理参数,对我们开发人员有莫大的帮助。

堆空间设置

使用-Xms-Xmx来指定JVM堆空间的初始值和最大值,比如:

java -Xms128m -Xmx2g app

虽然JVM可以在运行时动态的调整堆内存大小,但很多时候我们都直接将-Xms-Xmx设置相等的值,这样可以减少程序运行时进行垃圾回收的次数。

新生代设置

参数-Xmn用于设置新生代大小,设置一个较大的新生代会减少老年代的大小,这个参数堆GC行为影响很大。一般情况下不需要使用这个参数,在分析GC日志后,发现确实是因为新生代设置过小导致频繁的Full GC,可以配置这个参数,一般情况下,新生代设置为堆空间的1/3 - 1/4左右。

还可以通过-XX:SurviorRatio设置新生代中eden区和Survivor from/to区空间的比例关系,也可使用-XX:NewRatio设置新生代和老年代的比例。

配置这3个参数的基本策略是:尽可能将对象预留在新生代,减少老年代GC的次数,所以需要更谨慎的对其进行修改,不要太随意。

生成快照文件

我们可能没有办法给最大堆内存设置一个合适的值,因为我们时常面临内存溢出的状况,当然我们可以在内存溢出情况出现后,再监控程序,dump出内存快照来定位,但这种方法的前提条件是内存溢出问题要再次发生。更好方法是通过设置-XX:+HeapDumpOnOutOfMemoryError让JVM在发生内存溢出时自动的生成堆内存快照。有了这个参数,当我们在面对内存溢出异常的时候会节约大量的时间,-XX:HeapDumpPath则可以设置快照的生成路径。堆内存快照文件可能很庞大,要注意存储的磁盘空间。

方法区设置

方法区中存放中JVM加载的类信息,如果JVM加载的类过多,就需要合理设置永久大的大小,在JDK1.6和JDK1.7中,可以使用 -XX:PermSize-XX:MaxPermSize来达到这个目的,前者用于设置永久代的初始大小,后者用于设置永久代的最大值。前面我们知道,方法区并不在堆内存中,所以要注意所有JVM参数设置的内存总大小。

在JDK1.8中已经使用元空间代替永久代,同样的目的,需要使用-XX:MetaspaceSize-XX:MaxMetaspaceSize来代替。

直接内存

参数-XX:MaxDirectMemorySize用于配置直接内存大小 ,如果不设置,默认值为最大堆空间,即-Xmx,当直接内存使用量达到设置的值时,就会触发垃圾回收,如果垃圾回收不能有效释放足够空间,仍然会引起OOM。如果堆外内存发生OOM,请检查此参数是否配置过小。

2.4 小结

这部分主要介绍一些常用的JVM参数,理解这些JVM参数的前提是需要理解JVM的内存结构以及各个内存区域的作用,希望通过这些参数的介绍,能够加深大家对JVM内存结构的理解,也希望在平时的工作中能够注意这些参数的运用。下篇文章将着重介绍常用的垃圾回收算法与垃圾收集器。

参考资料

  1. 周志明 著; 深入理解Java虚拟机(第2版); 机械工业出版社,2013
  2. Java8内存模型—永久代(PermGen)和元空间(Metaspace)
  3. java虚拟机:运行时常量池
  4. 最简单例子图解JVM内存分配和回收
  5. JVM的内存区域划分
  6. JVM实用参数(八)GC日志
  7. JVM实用参数(四)内存调优
技术咖
Web note ad 1