美文网首页JVMjvm
JVM内存模型以及性能调优

JVM内存模型以及性能调优

作者: 码道功臣 | 来源:发表于2019-05-14 11:13 被阅读123次

    JVM 内存模型

    JVM.png

    程序计数器

    程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

    由于Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

    如果线程正在执行的是一个Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie 方法,这个计数器值则为空(Undefined)。

    此内存区域是唯一一个在Java 虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

    虚拟机栈

    虚拟机栈(Java Virtual Machine Stacks)是线程隔离的,每创建一个线程时就会对应创建一个Java栈,即每个线程都有自己独立的虚拟机栈。这个栈中又会对应包含多个栈帧,每调用一个方法时就会往栈中创建并压入一个栈帧,栈帧存储局部变量表、操作栈、动态链接、方法出口等信息,每一个方法从调用到最终返回结果的过程,就对应一个栈帧从入栈到出栈的过程。

    虚拟机栈是一个后入先出的数据结构,线程运行过程中,只有处于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法,当前活动帧栈始终是虚拟机栈的栈顶元素。

    • 局部变量表存放了编译期可知的各种基本数据类型和对象引用类型。通常我们所说的“栈内存”指的就是局部变量表这一部分。
    • 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧分配多少内存是固定的,运行期间不会改变局部变量表的大小。
    • 64位的long和double类型的数据会占用2个局部变量空间,其余的数据类型只占用1个。

    栈的大小可以固定也可以动态扩展。

    • 在固定大小的情况下,JVM会为每个线程的虚拟机栈分配一定的内存大小(-Xss参数),因此虚拟机栈能够容纳的栈帧数量是有限的,若栈帧不断进栈而不出栈,最终会导致当前线程虚拟机栈的内存空间耗尽,会抛出StackOverflowError异常。
    • 在动态扩展的情况下,当整个虚拟机栈内存耗尽,并且无法再申请到新的内存时,就会抛出OutOfMemoryError异常。

    在概念模型上,典型的栈帧结构如图所示:

    JVM Stack.jpg

    栈特点

    • 是一种运算受限的线性表。其限制是仅允许在表的一端进行插入和删除运算。这一端被称为栈顶,相对地,把 另一端称为栈底。其特性是先进后出;
    • 栈是线程私有的,生命周期跟线程相同,当创建一个线程时,同时会创建一个栈,栈的大小和深度都是固定的;
    • 方法参数列表中的变量,方法体中的基本数据类型的变量和引用数据类型的引用都存放在栈中,成员变量和对象本身不存放在栈中。运行时,成员函数的局部变量引用也存放在栈中;
    • 栈的变量随着变量作用域的结束而释放,不需要jvm垃圾回收机制回收;
    • 栈不是全局共享的,每个线程创建一个栈,该线程只能访问其对应的栈数据;
    • 栈内存的大小是在编译期就确定了的;

    栈帧

    一个栈中可以有多个栈帧,栈帧随着方法的调用而创建,随着方法的结束而消亡。该栈帧中存储该方法中的变量,原则上各个栈帧之间的数据是不能共享的,但是在方法间调用时,jvm会将一方法的返回值赋值给调用它的栈帧中。每一个方法调用,就是一个压栈的过程,每个方法的结束就是一个弹栈的过程。压栈都将会将该栈帧置于栈顶,每个栈不会同时操作多个栈帧,只会操作栈顶,当栈顶操作结束时,会将该栈帧弹出,同时会释放该栈帧内存,其下一个栈帧将变为栈顶。栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。

    栈中的优化,其一是当局部变量赋值时,会在栈空间中找其对应的值,当有该值时,将该值指向变量,当没有该值时,创建一个该值,然后再指向该变量,例如:int a = 1, int b = 1, b = 2; 其二是栈中的变量随着方法的调用而创建,当方法执行结束后,jvm会自动释放内存。

    栈帧的组成部分

    局部变量表:
    是一组变量值的存储空间,用呀存放方法参数和局部变量,虚拟机通过索引定位的方式使用局部变量表。

    操作树栈:
    常称为操作数栈,是一个后入先出栈。方法执行中进行算术运算或者是调用其他的方法进行参数传递的时候是通过操作数栈进行的。在概念模型中,两个栈帧是相互独立的。但是大多数虚拟机的实现都会进行优化,令两个栈帧出现一部分重叠。令下面的部分操作数栈与上面的局部变量表重叠在一块,这样在方法调用的时候可以共用一部分数据,无需进行额外的参数复制传递。

    动态连接:
    在说明什么是动态连接之前先看看方法的大概调用过程,首先在虚拟机运行的时候,运行时常量池会保存大量的符号引用,这些符号引用可以看成是每个方法的间接引用,如果代表栈帧A的方法想调用代表栈帧B的方法,那么这个虚拟机的方法调用指令就会以B方法的符号引用作为参数,但是因为符号引用并不是直接指向代表B方法的内存位置,所以在调用之前还必须要将符号引用转换为直接引用,然后通过直接引用才可以访问到真正的方法,这时候就有一点需要注意,如果符号引用是在类加载阶段或者第一次使用的时候转化为直接应用,那么这种转换成为静态解析,如果是在运行期间转换为直接引用,那么这种转换就成为动态连接。

    方法返回地址:
    方法的返回分为两种情况,一种是正常退出,退出后会根据方法的定义来决定是否要传返回值给上层的调用者,一种是异常导致的方法结束,这种情况是不会传返回值给上层的调用方法.不过无论是那种方式的方法结束,在退出当前方法时都会跳转到当前方法被调用的位置,如果方法是正常退出的,则调用者的PC计数器的值就可以作为返回地址,如果是因为异常退出的,则是需要通过异常处理表来确定.在方法的的一次调用就对应着栈帧在虚拟机栈中的一次入栈出栈操作,因此方法退出时可能做的事情包括,恢复上层方法的局部变量表以及操作数栈,如果有返回值的话,就把返回值压入到调用者栈帧的操作数栈中,还会把PC计数器的值调整为方法调用入口的下一条指令。

    栈优点

    栈帧内存数据共享:
    栈帧之间数据不能共享,但是同一个栈帧内的数据是可以共享的,这样设计是为了减小内存消耗,例如:int a = 1, int b= 1时,前面定义了a=1,a和1都在栈内存内,如果再定义一个b=1,此时将b放入栈内存,然后查找栈内存中是否有1,如果有则b指向1。如果再给b赋值2,则在栈内存中查找是否有2,如果没有就在栈内存中放一个2,然后b指向2。也就是如果常量在栈内存中,就将变量指向该常量,如果没有就在该栈内存增加一个该常量,并将变量指向该常量。

    存取速度比堆要快,仅次于寄存器:
    速度快之一是栈在编译器就申请好了内存空间,所以在运行时不需要申请内存大小,节约了时间,其二是栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。其三是访问时间,访问堆的一个具体单元,需要两次访问内存,第一次得取得指针,第二次才是真正得数据,而栈只需访问一次。

    栈的缺点

    存在栈的数据大小和生存期必须是确定的,缺乏灵活性。当栈在运行执行程序时,发现栈内存不够,不会动态的去申请内存,以至于导致程序报错,所以灵活性较差。

    本地方法栈

    本地方法栈(Native MethodStacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native 方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

    与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

    堆内存

    堆是Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。但是随着JIT 编译器的发展与逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。

    堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC 堆”。

    堆的大小可以通过-Xms(最小值)和-Xmx(最大值)参数设置,-Xms为JVM启动时申请的最小内存,默认为操作系统物理内存的1/64但小于1G,-Xmx为JVM可申请的最大内存,默认为物理内存的1/4但小于1G,默认当空余堆内存小于40%时,JVM会增大Heap到-Xmx指定的大小,可通过-XX:MinHeapFreeRation=来指定这个比列;当空余堆内存大于70%时,JVM会减小heap的大小到-Xms指定的大小,可通过XX:MaxHeapFreeRation=来指定这个比列,对于运行系统,为避免在运行时频繁调整Heap的大小,通常-Xms与-Xmx的值设成一样。

    如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java 堆中还可以细分为:新生代和老年代;

    新生代:程序新创建的对象都是从新生代分配内存,新生代由Eden Space和两块相同大小的Survivor Space(通常又称S0和S1或From和To)构成,可通过-Xmn参数来指定新生代的大小,也可以通过-XX:SurvivorRation来调整Eden Space及SurvivorSpace的大小。

    老年代:用于存放经过多次新生代GC仍然存活的对象,例如缓存对象,新建的对象也有可能直接进入老年代,主要有两种情况:1、大对象,可通过启动参数设置-XX:PretenureSizeThreshold=1024(单位为字节,默认为0)来代表超过多大时就不在新生代分配,而是直接在老年代分配。2、大的数组对象,且数组中无引用外部对象。

    老年代所占的内存大小为-Xmx对应的值减去-Xmn对应的值。

    方法区

    方法区在一个jvm实例的内部,类型信息被存储在一个称为方法区的内存逻辑区中。类型信息是由类加载器在类加载时从类文件中提取出来的。类(静态)变量也存储在方法区中。

    简单说方法区用来存储类型的元数据信息,一个.class文件是类被java虚拟机使用之前的表现形式,一旦这个类要被使用,java虚拟机就会对其进行装载、连接(验证、准备、解析)和初始化。而装载(后的结果就是由.class文件转变为方法区中的一段特定的数据结构,这个数据结构会存储如下信息:

    类型信息

    • 这个类型的全限定名
    • 这个类型的直接超类的全限定名
    • 这个类型是类类型还是接口类型
    • 这个类型的访问修饰符
    • 任何直接超接口的全限定名的有序列表

    字段信息

    • 字段名
    • 字段类型
    • 字段的修饰符

    方法信息

    • 方法名
    • 方法返回类型
    • 方法参数的数量和类型(按照顺序)
    • 方法的修饰符

    其他信息

    • 除了常量以外的所有类(静态)变量
    • 一个指向ClassLoader的指针
    • 一个指向Class对象的指针
    • 常量池(常量数据以及对其他类型的符号引用)

    JVM为每个已加载的类型都维护一个常量池。常量池就是这个类型用到的常量的一个有序集合,包括实际的常量(string,integer,和floating point常量)和对类型,域和方法的符号引用。池中的数据项象数组项一样,是通过索引访问的。

    每个类的这些元数据,无论是在构建这个类的实例还是调用这个类某个对象的方法,都会访问方法区的这些元数据。

    构建一个对象时,JVM会在堆中给对象分配空间,这些空间用来存储当前对象实例属性以及其父类的实例属性(而这些属性信息都是从方法区获得),注意,这里并不是仅仅为当前对象的实例属性分配空间,还需要给父类的实例属性分配,到此其实我们就可以回答第一个问题了,即实例化父类的某个子类时,JVM也会同时构建父类的一个对象。从另外一个角度也可以印证这个问题:调用当前类的构造方法时,首先会调用其父类的构造方法直到Object,而构造方法的调用意味着实例的创建,所以子类实例化时,父类肯定也会被实例化。

    类变量被类的所有实例共享,即使没有类实例时你也可以访问它。这些变量只与类相关,所以在方法区中,它们成为类数据在逻辑上的一部分。在JVM使用一个类之前,它必须在方法区中为每个non-final类变量分配空间。

    方法区主要有以下几个特点:

    • 方法区是线程安全的。由于所有的线程都共享方法区,所以,方法区里的数据访问必须被设计成线程安全的。例如,假如同时有两个线程都企图访问方法区中的同一个类,而这个类还没有被装入JVM,那么只允许一个线程去装载它,而其它线程必须等待
    • 方法区的大小不必是固定的,JVM可根据应用需要动态调整。同时,方法区也不一定是连续的,方法区可以在一个堆(甚至是JVM自己的堆)中自由分配。
    • 方法区也可被垃圾收集,当某个类不在被使用(不可触及)时,JVM将卸载这个类,进行垃圾收集

    可以通过-XX:PermSize 和 -XX:MaxPermSize 参数限制方法区的大小。

    对于习惯在HotSpot 虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(PermanentGeneration),本质上两者并不等价,仅仅是因为HotSpot 虚拟机的设计团队选择把GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机(如BEA JRockit、IBM J9 等)来说是不存在永久代的概念的。

    相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。

    堆内存和栈内存的对比

    经常有人把Java 内存区分为堆内存(Heap)和栈内存(Stack),这种分法比较粗糙,Java内存区域的划分实际上远比这复杂。这种划分方式的流行只能说明大多数程序员最关注的、与对象内存分配关系最密切的内存区域是这两块。

    堆很灵活,但是不安全。对于对象,我们要动态地创建、销毁,不能说后创建的对象没有销毁,先前创建的对象就不能销毁,那样的话我们的程序就寸步难行,所以Java中用堆来存储对象。而一旦堆中的对象被销毁,我们继续引用这个对象的话,就会出现著名的 NullPointerException,这就是堆的缺点——错误的引用逻辑只有在运行时才会被发现。

    栈不灵活,但是很严格,是安全的,易于管理。因为只要上面的引用没有销毁,下面引用就一定还在,在大部分程序中,都是先定义的变量、引用先进栈,后定义的后进栈,同时,区块内部的变量、引用在进入区块时压栈,区块结束时出栈,理解了这种机制,我们就可以很方便地理解各种编程语言的作用域的概念了,同时这也是栈的优点——错误的引用逻辑在编译时就可以被发现。

    栈--主要存放引用和基本数据类型。
    堆--用来存放 new 出来的对象实例。

    内存分配过程

    • JVM 会试图为相关Java对象在Eden Space中初始化一块内存区域。
    • 当Eden空间足够时,内存申请结束;否则到下一步。
    • JVM 试图释放在Eden中所有不活跃的对象(这属于1或更高级的垃圾回收)。释放后若Eden空间仍然不足以放入新对象,则试图将部分Eden中活跃对象放入Survivor区。
    • Survivor区被用来作为Eden及Old的中间交换区域,当Old区空间足够时,Survivor区的对象会被移到Old区,否则会被保留在Survivor区。
    • 当Old区空间不够时,JVM 会在Old区进行完全的垃圾收集(0级)。
    • 完全垃圾收集后,若Survivor及Old区仍然无法存放从Eden复制过来的部分对象,导致JVM无法在Eden区为新对象创建内存区域,则出现“outofmemory”错误。

    对象访问

    对象访问在Java 语言中无处不在,是最普通的程序行为,但即使是最简单的访问,也会却涉及Java 栈、Java 堆、方法区这三个最重要内存区域之间的关联关系。

    如下面的这句代码:
    Object obj = newObject();

    假设这句代码出现在方法体中,那“Object obj”这部分的语义将会反映到Java 栈的本地变量表中,作为一个reference 类型数据出现。而“new Object()”这部分的语义将会反映到Java 堆中,形成一块存储了Object 类型所有实例数据值(Instance Data,对象中各个实例字段的数据)的结构化内存,根据具体类型以及虚拟机实现的对象内存布局(Object Memory Layout)的不同,这块内存的长度是不固定的。另外,在Java 堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。

    由于reference 类型在Java 虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java 堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄和直接指针。

    如果使用句柄访问方式,Java 堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
    如图:


    JVM1.jpg

    如果使用直接指针访问方式,reference 中直接存储的就是对象地址.
    如图:


    JVM2.jpg

    总结

    名称 特征 作用 配置参数 异常
    程序计数器 占用内存小,线程私有,生命周期与线程相同 大致为字节码行号指示器
    虚拟机栈 线程私有,生命周期与线程相同,使用连续的内存空间 Java 方法执行的内存模型,存储局部变量表、操作栈、动态链接、方法出口等信息 -Xss StackOverflowError、OutOfMemoryError
    java堆 线程共享,生命周期与虚拟机相同,可以不使用连续的内存地址 保存对象实例,所有对象实例(包括数组)都要在堆上分配 -Xms、-Xmx、-Xmn OutOfMemoryError
    方法区 线程共享,生命周期与虚拟机相同,可以不使用连续的内存地址 存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据 -XX:PermSize:16M、-XX:MaxPermSize:64M OutOfMemoryError
    运行时常量池 方法区的一部分,具有动态性 存放字面量及符号引用

    JVM 垃圾回收机制

    哪些内存需要回收

    JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

    垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,哪些暂时还不能回收,这就要用到判断对象是否存活的算法,常用的算法有“引用计数算法”、“可达性分析算法”!

    引用计数算法
    引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。

    优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。

    缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。

    public class ReferenceFindTest {
        public static void main(String[] args) {
            MyObject object1 = new MyObject();
            MyObject object2 = new MyObject();
              
            object1.object = object2;
            object2.object = object1;
              
            object1 = null;
            object2 = null;
        }
    }
    

    这段代码是用来验证引用计数算法不能检测出循环引用。最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

    可达性分析算法

    可达性分析算法是从离散数学中的图论引入的,就是通过一系列名为 “ GC Roots ”的对象为起点,然后开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(在图里面称为路径)时,则证明此对象是不可达的。

    GC5.jpeg

    在Java语言中,可作为GC Roots的对象包括下面几种:

    • 虚拟机栈中引用的对象(栈帧中的本地变量表);
    • 方法区中类静态属性引用的对象;
    • 方法区中常量引用的对象;
    • 本地方法栈中JNI(Native方法)引用的对象。

    常用的垃圾回收算法

    标记-清除(mark-and-sweep、Tracing)
    这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。

    标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。
    具体过程如下图所示:


    GC1.jpg

    标记-复制(Copying)
    为了解决Mark-And-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。

    这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。

    在CMS垃圾收集器中,新生代里面分为一个Eden区和两个survivor区,默认Eden与survivor区的占比是8:1:1,也就是说新生代中,内存利用的有效率为80%+10%=90%,仅有10%是浪费掉的。当然并不是每次存活的对象会低于10%,如果大于10%,那么这些对象就会通过分配担保机制进入老年代。在经历一次新生代GC后,后入新到来的对象如果eden区能够容纳,仍然会放在新生代中。

    具体过程如下图所示:


    GC2.jpg

    标记-整理(Compacting)
    为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。
    具体过程如下图所示:

    GC3.jpg

    分代收集(Generation)
    分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

    目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。

    而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact算法。

    我们从一个object1来说明其在分代垃圾回收算法中的回收轨迹

    • object1新建,出生于新生代的Eden区域。


      image
    • minor GC,object1 还存活,移动到Fromsuvivor空间,此时还在新生代。


      image
    • minor GC,object1 仍然存活,此时会通过复制算法,将object1移动到ToSuv区域,此时object1的年龄age+1。


      image
    • minor GC,object1 仍然存活,此时survivor中和object1同龄的对象并没有达到survivor的一半,所以此时通过复制算法,将fromSuv和Tosuv 区域进行互换,存活的对象被移动到了Tosuv。


      image
    • minor GC,object1 仍然存活,此时survivor中和object1同龄的对象已经达到survivor的一半以上(toSuv的区域已经满了),object1被移动到了老年代区域。


      image
    • object1存活一段时间后,发现此时object1不可达GcRoots,而且此时老年代空间比率已经超过了阈值,触发了majorGC(也可以认为是fullGC,但具体需要垃圾收集器来联系),此时object1被回收了。fullGC会触发 stop the world。


      image

    在以上的新生代中,我们有提到对象的age,对象存活于survivor状态下,不会立即晋升为老生代对象,以避免给老生代造成过大的影响,它们必须要满足以下条件才可以晋升:
    1, minor gc 之后,存活于survivor 区域的对象的age会+1,当超过(默认)15的时候,转移到老年代。
    2, 动态对象,如果survivor空间中相同年龄所有的对象大小的综合和大于survivor空间的一半,年级大于或等于该年级的对象就可以直接进入老年代。

    JVM 性能调优

    调优目标

    GC优化的基本方法是:将不同的JVM配置,应用到多个相同的机器环境中,对比找到可以提高性能、减少GC时间和次数的配置。

    • 降低进入老年代的对象数量
      除了可以在 JDK7 及更高版本中使用的 G1 收集器以外,其他分代 GC 都是由 Oracle JVM 提供的。关于分代 GC,就是对象在 Eden 区被创建,经过多次Minor GC、Survivor交换后,任然存活的对象会被转入老年代。也有一些对象由于占用内存过大,在 Eden 区被创建后会直接被传入老年代。老年代 GC 相对来说会比新生代 GC 更耗时,因此,减少进入老年代的对象数量可以显著降低 Full GC 的频率。

    • 减少FULL GC执行的时间和次数
      Full GC 的执行时间比 Minor GC 要长很多,因此,如果 Full GC 时间过长(超过 1s),将可能出现超时错误。如果通过减小老年代内存来减少 Full GC 时间,可能会引起 OutOfMemoryError 或者导致 Full GC 的频率升高。如果通过增加老年代内存来降低 Full GC 的频率,Full GC 的时间可能会增长。所以你需要通过不断的实验对比,找到一个“合适”的值。

    下面情况无需进行GC优化:

    • Minor GC执行时间不到50ms;
    • Minor GC执行不频繁,约10秒一次;
    • Full GC执行时间不到1s;
    • Full GC执行频率不算频繁,不低于10分钟1次;

    JVM启动参数

    参数 说明 实例
    -Xms 初始堆大小,默认物理内存的1/64 -Xms512M
    -Xmx 最大堆大小,默认物理内存的1/4 -Xms2G
    -Xmn 新生代内存大小,官方推荐为整个堆的3/8 -Xmn512M
    -Xss 线程堆栈大小,jdk1.5及之后默认1M,之前默认256k -Xss512k
    -XX:NewRatio=n 设置新生代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4 -XX:NewRatio=3
    -XX:SurvivorRatio=n 年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:8,表示Eden:Survivor=8:1:1,一个Survivor区占整个年轻代的1/8 -XX:SurvivorRatio=8
    -XX:PermSize=n 永久代初始值,默认为物理内存的1/64 -XX:PermSize=128M
    -XX:MaxPermSize=n 永久代最大值,默认为物理内存的1/4 -XX:MaxPermSize=256M
    -verbose:class 在控制台打印类加载信息 -
    -verbose:gc 在控制台打印垃圾回收日志 -
    -XX:+PrintGC 打印GC日志,内容简单 -
    -XX:+PrintGCDetails 打印GC日志,内容详细 -
    -XX:+PrintGCDateStamps 在GC日志中添加时间戳 -
    -Xloggc:filename 指定gc日志路径 -Xloggc:/data/jvm/gc.log
    -XX:+UseSerialGC 年轻代设置串行收集器Serial -
    -XX:+UseParallelGC 年轻代设置并行收集器Parallel Scavenge -
    -XX:ParallelGCThreads=n 设置Parallel Scavenge收集时使用的CPU数。并行收集线程数。 -XX:ParallelGCThreads=4
    -XX:MaxGCPauseMillis=n 设置Parallel Scavenge回收的最大时间(毫秒) -XX:MaxGCPauseMillis=100
    -XX:GCTimeRatio=n 设置Parallel Scavenge垃圾回收时间占程序运行时间的百分比。公式为1/(1+n) -XX:GCTimeRatio=19
    -XX:+UseParallelOldGC 设置老年代为并行收集器ParallelOld收集器 -
    -XX:+UseConcMarkSweepGC 设置老年代并发收集器CMS -
    -XX:+CMSIncrementalMode 设置CMS收集器为增量模式,适用于单CPU情况。 -

    配置建议
    JVM配置方面,一般情况可以先用默认配置(基本的一些初始参数可以保证一般的应用跑的比较稳定了),在测试中根据系统运行状况(会话并发情况、会话时间等),结合GC日志、内存监控、使用的垃圾收集器等进行合理的调整。当老年代内存过小时可能引起频繁Full GC,过大时Full GC时间会特别长。

    那么JVM的配置比如新生代、老年代应该配置多大最合适呢?答案是不一定,调优就是找答案的过程。物理内存一定的情况下,新生代设置越大,老年代就越小,Full GC频率就越高,但Full GC时间越短;相反新生代设置越小,老年代就越大,Full GC频率就越低,但每次Full GC消耗的时间越大

    • 发现FullGC频繁的时候优先调查内存泄漏问题

    • -Xms和-Xmx的值设置成相等。堆大小默认为-Xms指定的大小,默认空闲堆内存小于40%时,JVM会扩大堆到-Xmx指定的大小;空闲堆内存大于70%时,JVM会减小堆到-Xms指定的大小。如果在Full GC后满足不了内存需求会动态调整,这个阶段比较耗费资源,所以设置成想通知,以避免每次垃圾回收完成后JVM重新分配内存。

    • 年轻代的设置,整个JVM内存大小=年轻代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。

    • 避免创建过大的对象及数组:过大的对象或数组在新生代没有足够空间容纳时会直接进入老年代,如果是短命的大对象,会提前出发Full GC。

    • 避免同时加载大量数据,如一次从数据库中取出大量数据,或者一次从Excel中读取大量记录,可以分批读取,用完尽快清空引用。

    • 当集合中有对象的引用,这些对象使用完之后要尽快把集合中的引用清空,这些无用对象尽快回收避免进入老年代。

    • 尽量避免长时间等待外部资源(数据库、网络、设备资源等)的情况,缩小对象的生命周期,避免进入老年代,如果不能及时返回结果可以适当采用异步处理的方式等。

    调优工具

    jmap (Memory Map for Java):
    可以生成 java 程序的 dump 文件, 也可以查看堆内对象示例的统计信息、查看 ClassLoader 的信息以及 finalizer 队列

    命令:jmap pid
    描述:查看进程的内存映像信息,类似 Solaris pmap 命令。

    命令:jmap -heap pid
    描述:显示Java堆详细信息

    命令:jmap -histo:live pid
    描述:显示堆中对象的统计信息

    命令:jmap -clstats pid
    描述:打印类加载器信息

    命令:jmap -finalizerinfo pid
    描述:打印等待终结的对象信息

    命令:jmap -dump:format=b,file=heapdump.phrof pid
    描述:生成堆转储快照dump文件。

    jstack:
    线程跟踪工具,用于打印指定Java进程的线程堆栈信息。
    jstack -l 5524 > /opt/jstack.txt

    jps (JVM process Status):
    可以查看虚拟机启动的所有进程、执行主类的全名、JVM启动参数。

    jstat (JVM Statistics Monitoring Tool):
    可以查看堆内存各部分的使用量,以及加载类的数量。

    命令:jstat -gc pid
    描述:垃圾回收统计

    命令:jstat -gccapacity pid
    描述:堆内存统计

    命令:jstat -gcnew pid
    描述:新生代垃圾回收统计

    命令:jstat -gcnewcapacity pid
    描述:新生代内存统计

    命令:jstat -gcold pid
    描述:老年代垃圾回收统计

    命令:jstat -gcoldcapacity pid
    描述:老年代内存统计

    命令:jstat -gcmetacapacity pid
    描述:元数据空间统计

    命令:jstat -gcutil pid
    描述:总结垃圾回收统计

    jstat -gc pid 500 10 :每500毫秒打印一次Java堆状况(各个区的容量、使用容量、gc时间等信息),打印10次

     S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT   
    12288.0 12800.0  0.0    0.0   642048.0 244137.2  102400.0   33034.0   60080.0 57964.0 7936.0 7472.9     11    0.898   3      1.940    2.838
    12288.0 12800.0  0.0    0.0   642048.0 244137.2  102400.0   33034.0   60080.0 57964.0 7936.0 7472.9     11    0.898   3      1.940    2.838
    12288.0 12800.0  0.0    0.0   642048.0 244137.2  102400.0   33034.0   60080.0 57964.0 7936.0 7472.9     11    0.898   3      1.940    2.838
    12288.0 12800.0  0.0    0.0   642048.0 244137.2  102400.0   33034.0   60080.0 57964.0 7936.0 7472.9     11    0.898   3      1.940    2.838
    12288.0 12800.0  0.0    0.0   642048.0 244137.2  102400.0   33034.0   60080.0 57964.0 7936.0 7472.9     11    0.898   3      1.940    2.838
    
    名称 描述
    S0C 第一个幸存区的大小
    S1C 第二个幸存区的大小
    S0U 第一个幸存区的使用大小
    S1U 第二个幸存区的使用大小
    EC 伊甸园区的大小
    EU 伊甸园区的使用大小
    OC 老年代大小
    OU 老年代使用大小
    MC 方法区大小
    MU 方法区使用大小
    CCSC 压缩类空间大小
    CCSU 压缩类空间使用大小
    YGC 年轻代垃圾回收次数
    YGCT 年轻代垃圾回收消耗时间
    FGC 老年代垃圾回收次数
    FGCT 老年代垃圾回收消耗时间
    GCT 垃圾回收消耗总时间

    jhat:
    用来分析java堆的命令,可以将堆中的对象以html的形式显示出来,包括对象的数量,大小等等,并支持对象查询语言。

    jinfo:
    可以查看运行中jvm的全部参数,还可以设置部分参数。
    jinfo pid

    相关文章

      网友评论

        本文标题:JVM内存模型以及性能调优

        本文链接:https://www.haomeiwen.com/subject/juajaqtx.html