JVM内存区域(Java内存区域)、JVM垃圾回收机制(GC)初探

一、JVM内存区域(Java内存区域)

  首先区分一下JVM内存区域(Java内存区域)和Java内存模型(JMM)的概念。Java线程之间的通信采用的是共享内存模型,这里提到的共享内存模型指的就是Java内存模型(简称JMM),Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在;Java线程之间的通信由JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。而JVM内存区域,有的地方也称之为Java内存区域,是JVM对JMM的实现。在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区,也可以理解为线程共享内存区和线程私有内存区。接下来就具体介绍分析一下JVM内存区域。

  Java程序是交由JVM执行的,所以我们在谈Java内存区域划分的时候事实上是指JVM内存区域划分。在讨论JVM内存区域划分之前,先来看一下Java程序具体执行的过程:

  从图中可以看到,JVM主要由三大部分组成:类加载器、执行引擎、运行时数据区。本篇博客主要介绍运行时数据区部分,类加载器会后续单独在一篇博客中介绍。

  如上图所示,首先Java源代码文件(.java后缀)会被Java编译器编译为字节码文件(.class后缀),然后由JVM中的类加载器中的defineClass()方法加载各个类的字节码文件进jvm内存中,生成java.lang.Class对象,每个类在JVM中都拥有一个对应的java.lang.Class对象,并存放于运行时数据区(Runtime Data Area)的堆中,它提供了类结构信息的描述。数组、枚举及基本Java类型(如int、double等)甚至void都拥有对应的Class对象。这也就是我们常说的Java类的静态加载,即程序在运行时,所需要的类就必须加载好,如果编译时这个类的.class文件不存在,程序将编译出错无法运行;还有一种是动态加载是通过反射机制在运行时用Class.forName()加载字节码文件获得java.lang.Class对象并放到JVM内存中。静态加载和动态加载的区别:https://www.jb51.net/article/93998.htm

  在整个程序执行过程中,JVM会用一段空间来存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作为Runtime Data Area(运行时数据区),也就是我们常说的JVM内存。因此,在Java中我们常常说到的内存管理就是针对这段空间进行管理(如何分配和回收内存空间)。

  我们接下来着重分析一下Runtime Data Area的各个区域:

  Java虚拟机在运行程序时会把其自动管理的内存划分为以上几个区域,每个区域都有的用途以及创建销毁的时机,其中蓝色部分代表的是所有线程共享的数据区域,而绿色部分代表的是每个线程的私有数据区域。

  • 方法区(Method Area):

  方法区属于线程共享的内存区域,又称Non-Heap(非堆)它与堆一样,是被线程共享的区域。方法区主要用于存储已被虚拟机加载的类信息(包括类的名称、方法信息、字段信息)、常量(final、字符串)、静态变量(static)、静态方法、成员方法、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。这块内存在程序编译时就已经分配好,并且在程序整个运行期间都存在。

  值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。

  在JVM规范中,没有强制要求方法区必须实现垃圾回收。很多人习惯将方法区称为“永久代”,是因为HotSpot虚拟机以永久代来实现方法区,从而JVM的垃圾收集器可以像管理堆区一样管理这部分区域,从而不需要专门为这部分设计垃圾回收机制。不过自从JDK7之后,Hotspot虚拟机便将运行时常量池从永久代移除了。

  • JVM堆(Java Heap):

  在C语言中,堆这部分空间是唯一一个程序员可以管理的内存区域。程序员可以通过malloc函数和free函数在堆上申请和释放空间。那么在Java中是怎么样的呢?

  JVM堆是动态内存分配,通常就是指在程序运行时直接 new 出来的内存,也就是对象的实例。这部分内存在不使用时将会由 Java 垃圾回收器来负责回收。

  Java中的堆是用来存储对象本身的以及数组(当然,数组引用是存放在Java栈中的)。只不过和C语言中的不同,在Java中,程序员基本不用去关心空间释放的问题,Java的垃圾回收机制会自动进行处理。因此这部分空间也是Java垃圾收集器管理的主要区域。另外,堆是被所有线程共享的,在JVM中只有一个堆。

  Java中的堆是用来存储对象本身的以及数组(当然,数组引用是存放在Java栈中的),也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java虚拟机所管理的内存中最大的一块,主要用于存放对象内存实例,几乎所有的对象实例都在这里分配内存,注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。另外,堆是被所有线程共享的,在JVM中只有一个堆。

  • 程序计数器(Program Counter Register):

  属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 虚拟机栈(Java Virtual Machine Stacks):

  也称Java栈,属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。栈中只保存基础数据类型和自定义对象的引用(不是对象),对象都存放在堆区中,如果在栈中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出StackOutflowError 异常。

  当方法被执行时,方法体内的局部变量(其中包括基础数据类型、对象的引用)都在栈上创建,并在方法执行结束时这些局部变量所持有的内存将会自动被释放。因为栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

  栈是自动分配连续的空间,后进先出(后调用的方法执行完后才继续执行外层的调用它的方法)。

  每个方法执行时都会创建一个栈桢,每个方法从调用直结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程,在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池(运行时常量池的概念在方法区部分会谈到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。讲到这里,大家就应该会明白为什么 在 使用 递归方法的时候容易导致栈内存溢出的现象了以及为什么栈区的空间不用程序员去管理了(当然在Java中,程序员基本不用关系到内存分配和释放的事情,因为Java有自己的垃圾回收机制),这部分空间的分配和释放都是由系统自动实施的。对于所有的程序设计语言来说,栈这部分空间对程序员来说是不透明的。下图表示了一个Java栈的模型:

 

  局部变量表,顾名思义,想必不用解释大家应该明白它的作用了吧。就是用来存储方法中的局部变量(包括在方法中声明的非静态变量以及函数形参)。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。

  操作数栈,想必学过数据结构中的栈的朋友想必对表达式求值问题不会陌生,栈最典型的一个应用就是用来对表达式求值。想想一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。

  指向运行时常量池的引用,因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。

  方法返回地址,当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。

  由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,互不干扰。

  • 本地方法栈(Native Method Stacks):

  本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关,一般情况下,我们无需关心此区域。本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方发展的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。

 

二、JVM垃圾回收机制(GC)(垃圾收集、回收算法、垃圾回收器)

 2.1 垃圾收集(哪些垃圾需要回收) 

  我们都知道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;
    }
}

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

  • 可达性分析算法

  可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。

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

  a) 虚拟机栈中引用的对象(栈帧中的本地变量表);

  b) 方法区中类静态属性引用的对象;

  c) 方法区中常量引用的对象;

  d) 本地方法栈中JNI(Native方法)引用的对象。

  • Java中的引用

  无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在Java语言中,将引用又分为强引用、软引用、弱引用、虚引用4种,这四种引用强度依次逐渐减弱。不管是引用计数算法还是可达性分析算法都只与强引用有关,所以另外三种引用就不过多介绍。

    • 强引用

    在程序代码中普遍存在的,类似 Object obj = new Object() 这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

  • 回收前的救赎
  上面提到了判断死亡的依据,但被判断死亡后,还有生还的机会。
  自我救赎需要满足两个条件:
  1.对象覆写了finalize()方法(这样在被判死后才会调用此方法,才有机会做最后的救赎);
  2.在finalize()方法中重新引用到”GC  Roots”链上(如把当前对象的引用this赋值给某对象的类变量/成员变量,重新建立可达的引用).
  需要注意:
  finalize()只会在对象内存回收前被调用一次(The finalize method is never invoked more than once by a Java virtual machine for any given object. );
  finalize()的调用具有不确定性,只保证方法会调用,但不保证方法里的任务会被执行完(比如一个对象手脚不够利索,磨磨叽叽,还在自救的过程中,被杀死回收了)。
  • 方法区(非堆)如何判断是否需要被回收

方法区存储内容是否需要回收的判断可就不一样咯。方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面3个条件:

    • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
    • 加载该类的ClassLoader已经被回收;
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

 

 2.2 常见的垃圾收集算法

  • 标记-清除算法

  标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如下图所示。标记-清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。

  • 复制算法

  复制算法的提出是为了克服句柄的开销和解决内存碎片的问题。它开始时把堆分成 一个对象 面和多个空闲面, 程序从对象面为对象分配空间,当对象满了,基于copying算法的垃圾 收集就从根集合(GC Roots)中扫描活动对象,并将每个 活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。

  • 标记-整理算法

  标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。具体流程见下图:

  • 分代收集算法

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

  • 年轻代(Young Generation)的回收算法

  a) 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。

  b) 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。好处:使用对象最多和效率最高的就是在Young Generation中,通过From to就避免过于频繁的产生FullGC(Old Generation满了一般都会产生FullGC)。

  c) 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。

  d) 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。

  e) 虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor区,并将对象年龄设为 1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1,当它的年龄增加到一定程度(默认为15)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

  f) 比较大的对象,数组等,大于某值(可配置)就直接分配到年老代,(避免频繁内存拷贝)

  g) 由于年轻代堆空间的垃圾回收会很频繁,因此其垃圾回收算法会更加重视回收效率

  • 年老代(Old Generation)的回收算法 

  a) 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

  b) 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率高。

  c) 这个堆空间通常比年轻代的堆空间大,并且其空间增长速度较缓

  d) 由于大部分JVM堆空间都分配给了年老代,因此其垃圾回收算法需要更节省空间,此算法需要能够处理低垃圾密度的堆空间

  • 持久代(Permanent Generation)的回收算法

  用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代也称方法区,具体的回收方法可参照上面写的方法区(非堆)如何判断是否需要被回收。

  一般当Eden区分配内存失败时(Eden区内存满了)就会发生Minor GC,当老年代也放满了就会发生Full GC,如果Full GC以后,老年代空间还是不够,就会发生OOM了。

 

 2.3 常见的垃圾收集器

垃圾回收器负责:

    • 分配内存
    • 保证所有正在被引用的对象还存在于内存中
    • 回收执行代码已经不再引用的对象所占的内存

下面一张图是HotSpot虚拟机包含的所有收集器。

    • Serial收集器(复制算法)

  新生代单线程收集器,标记和清理都是单线程,优点是简单高效。是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。

    • Parallel Scavenge收集器(停止-复制算法)

新生代并行收集器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。

    • ParNew收集器(停止-复制算法) 

新生代并发收集器,可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。

    • Serial Old收集器(标记-整理算法)  :

老年代单线程收集器,Serial收集器的老年代版本。

    • Parallel Old收集器(标记-整理算法)

老年代并行收集器,Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先。

    • CMS(Concurrent Mark Sweep)收集器(标记-清除算法)

  老年代并发收集器。高并发、低停顿,追求最短GC回收停顿时间,但是是以牺牲吞吐量和cpu占用率为代价,多核cpu追求高响应时间的选择。

  前5个收集器都会“Stop the world”,即在垃圾收集阶段,都会暂停用户线程,

  而CMS是第一款真正意义上的并发(Concurrent)收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;

  从名字上看出,CMS收集器是基于“标记-清除”算法实现的,它的运作过程整体来说分为四部:

 

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中第一步第三部仍需要“stop the world”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段长一些,但远比并发标记时间短。

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以和用户线程一起工作,所以,总体上来说,CMS收集器的内存回收过程与用户线程一起并发执行的。

CMS收集器的缺点:

  • CMS由于和用户线程并发执行,所以对CPU资源较为敏感,即虽然CMS不会导致用户线程停顿,但会因为占用一部分CPU资源导致用户线程变慢,总吞吐量降低。
  • CMS无法处理浮动垃圾。由于并发清理阶段用户线程还在运行,伴随程序运行自然就会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理掉他们。由于垃圾收集阶段用户线程还需要运行,所以CMS不能像其他收集器那样等老年代满了再进行收集,需要预留一部分空间提供并发收集时程序运作使用。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”,这时JVM启动备案,启用Serial Old收集器来重新对老年代进行垃圾收集,这样的停顿时间就长了。
  • 由于CMS是基于“标记——清除”算法实现的,所以垃圾收集后会产生大量空间碎片

有关CMS收集器的的详细介绍可参考:https://blog.csdn.net/wfh6732/article/details/57490195?utm_source=itdadao&utm_medium=referral

    • G1(Garbage first)垃圾收集器:也是以关注延迟为目标、服务器端应用的垃圾收集器,被HotSpot团队寄予取代CMS的使命,也是一个非常具有调优潜力的垃圾收集器。虽然G1也有类似CMS的收集动作:初始标记、并发标记、重新标记、清除、转移回收,并且也以一个串行收集器做担保机制,但单纯地以类似前三种的过程描述显得并不是很妥当。事实上,G1收集与以上三组收集器有很大不同:

  1. G1的设计原则是”首先收集尽可能多的垃圾(Garbage First)“。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;
  2. G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩);
  3. G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换;
  4. G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。
  5.   能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;

 

有关G1收集器的详细介绍可参考:https://www.jianshu.com/p/0f1f5adffdc1

http://ifeve.com/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3g1%E5%9E%83%E5%9C%BE%E6%94%B6%E9%9B%86%E5%99%A8/

  垃圾收集器的内容较多,但都很有规律,例如 年轻代的收集器都采用复制算法,年老代的收集器都采用标记算法。此部分应着重理解并发收集器和并行收集器的区别,以及CMS收集器和G1收集器的原理。

  由于篇幅问题,在本篇博客就不对每个垃圾收集器做详细说明,附上一个详情链接:

  https://blog.csdn.net/tjiyu/article/details/53983650

 

 2.4 什么时候会发生GC(重点!!)  

  • 新生代的GC(Minor GC):

  一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Minor GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。

  新生代通常存活时间较短基于复制算法进行回收,所谓Copying算法就是扫描出存活的对象,并复制到一块新的完全未使用的空间中,对应于新生代,就是在Eden和FromSpace或ToSpace之间copy。新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。当连续分配对象时,对象会逐渐从Eden到Survivor,最后到老年代。

  在执行机制上JVM提供了串行GC(SerialGC)、并行回收GC(ParallelScavenge)和并发GC(ParNew):

  串行GC:在整个扫描和复制过程采用单线程的方式来进行,适用于单CPU、新生代空间较小及对暂停时间要求不是非常高的应用上,是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。

  并行回收GC:在整个扫描和复制过程采用多线程的方式来进行,适用于多CPU、对暂停时间要求较短的应用上,是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。

  并发GC:与老年代的并发GC配合使用。

  • 老年代的GC(Major GC/Full GC):

  对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比Minor GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节。有如下原因可能导致Full GC:

a) 年老代(Tenured)被写满;

b) 持久代(Perm)被写满;

c) System.gc()被显示调用;

d) 上一次GC之后Heap的各域分配策略动态变化;

  老年代与新生代不同,老年代对象存活的时间比较长、比较稳定,因此采用标记算法来进行回收,所谓标记就是扫描出存活的对象,然后再进行回收未被标记的对象,回收后对用空出的空间要么进行合并、要么标记出来便于下次进行分配,总之目的就是要减少内存碎片带来的效率损耗。

  在执行机制上JVM提供了串行GC(Serial MSC)、并行GC(Parallel MSC)和并发GC(CMS)。

  串行GC(Serial MSC):client模式下的默认GC方式,可通过-XX:+UseSerialGC强制指定。每次进行全部回收,进行Compact,非常耗费时间。

  并行GC(Parallel MSC)(吞吐量大,但是GC的时候响应很慢):server模式下的默认GC方式,也可用-XX:+UseParallelGC=强制指定。可以在选项后加等号来制定并行的线程数。

  并发GC(CMS)(响应比并行gc快很多,但是牺牲了一定的吞吐量):使用CMS是为了减少GC执行时的停顿时间,垃圾回收线程和应用线程同时执行,可以使用-XX:+UseConcMarkSweepGC=指定使用,后边接等号指定并发线程数。CMS每次回收只停顿很短的时间,分别在开始的时候(Initial Marking),和中间(Final Marking)的时候,第二次时间略长。CMS一个比较大的问题是碎片和浮动垃圾问题(Floating Gabage)。碎片是由于CMS默认不对内存进行Compact所致,可以通过-XX:+UseCMSCompactAtFullCollection。

 

 2.5 JVM命令行参数

  无论是客户端应用还是服务器端应用,一旦系统运行缓慢并且垃圾回收所占时间过长,你就会希望通过调整堆大小来改善这一点。不过,为了不影响其他也跑在同一个系统中的应用,不应该将堆大小设置的过大。

  GC调优是很重要的。找到最佳的分代堆空间是一个迭代的过程[3,10,12]。这里我们假定你已经为你的应用找到了最佳堆大小。那么你可以采用下面的JVM命令来进行设置:

JVM command

    • -Xmx: 设置堆内存的最大值。
    • -Xms: 设置堆内存的初始值。
    • -Xmn: 设置新生代的大小。
    • -Xss: 设置栈的大小。
    • -PretenureSizeThreshold: 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配。
    • -MaxTenuringThrehold: 晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC之后,年龄就会加1,当超过这个参数值时就进入老年代。
    • -UseAdaptiveSizePolicy: 在这种模式下,新生代的大小、eden 和 survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。在手工调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量 (GCTimeRatio) 和停顿时间 (MaxGCPauseMills),让虚拟机自己完成调优工作。
    • -SurvivorRattio: 新生代Eden区域与Survivor区域的容量比值,默认为8,代表Eden: Suvivor= 8: 1。
    • -XX:ParallelGCThreads:设置用于垃圾回收的线程数。通常情况下可以和 CPU 数量相等。但在 CPU 数量比较多的情况下,设置相对较小的数值也是合理的。
    • -XX:MaxGCPauseMills:设置最大垃圾收集停顿时间。它的值是一个大于 0 的整数。收集器在工作时,会调整 Java 堆大小或者其他一些参数,尽可能地把停顿时间控制在 MaxGCPauseMills 以内。
    • -XX:GCTimeRatio:设置吞吐量大小,它的值是一个 0-100 之间的整数。假设 GCTimeRatio 的值为 n,那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集。

  最后一点,最早在Java SE 5.0中有对服务器的人机工程学的介绍。这个可以很好的减少服务器端应用的调优时间,尤其是在堆大小测量和复杂GC调优方面。很多情况下,服务器端调优的最好方式就是不去调优。

 

三、Java8的变化

  以上讲的那些都是Java7的内存结构,在Java8中取消了堆内存中的永久代(方法区)的说法,将这部分内存中的常量池和静态变量放到了Java堆中、将类的元数据移到了本地内存中(元空间Metaspace),也就是说将原来方法区中的变量移到了GC所管理的JVM内存区域外。

  JDK 1.7 及以往的 JDK 版本中,Java 类信息、常量池、静态变量都存储在 Perm(永久代)里。类的元数据和静态变量在类加载的时候分配到 Perm,当类被卸载的时候垃圾收集器从 Perm 处理掉类的元数据和静态变量。当然常量池的东西也会在 Perm 垃圾收集的时候进行处理。

  JDK 1.8 的对 JVM 架构的改造将类元数据放到本地内存中,另外,将常量池和静态变量放到 Java 堆里。HotSopt VM 将会为类的元数据明确分配和释放本地内存。在这种架构下,类元信息就突破了原来 -XX:MaxPermSize 的限制,现在可以使用更多的本地内存。这样就从一定程度上解决了原来在运行时生成大量类的造成经常 Full GC 问题,如运行时使用反射、代理等。

为什么移除永久代? 
1、字符串存在永久代中,容易出现性能问题和内存溢出。 
2、永久代大小不容易确定,PermSize指定太小容易造成永久代OOM 
3、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。 
4、Oracle 可能会将HotSpot 与 JRockit 合二为一。

  Metaspace VM利用内存管理技术来管理Metaspace。这使得由不同的垃圾收集器来处理类元数据的工作,现在仅仅由Metaspace VM在Metaspace中通过C++来进行管理。Metaspace背后的一个思想是,类和它的元数据的生命周期是和它的类加载器的生命周期一致的。也就是说,只要类的类加载器是存活的,在Metaspace中的类元数据也是存活的,不能被释放。

 

参考链接:

https://www.cnblogs.com/dolphin0520/p/3613043.html

https://www.cnblogs.com/1024Community/p/honery.html#22-%E5%8F%AF%E8%BE%BE%E6%80%A7%E5%88%86%E6%9E%90%E7%AE%97%E6%B3%95

http://www.importnew.com/1551.html 

https://blog.csdn.net/aijiudu/article/details/72991993

https://blog.csdn.net/u012500848/article/details/51355404

 

 

 

posted @ 2018-08-29 18:04  Allegro  阅读(1190)  评论(0编辑  收藏  举报