JVM面试基础及常见面试题

本文详细介绍了JVM内存结构,包括JDK、JRE和JVM的关系,以及虚拟机栈、本地方法栈、程序计数器、堆和方法区的详细内容。讨论了各种内存区域的特性和作用,如线程私有区域的生命周期,以及堆和方法区的共享特性。文章还深入讲解了JVM内存溢出和栈溢出的类型及其原因,以及如何通过调整JVM参数解决这些问题。最后,探讨了垃圾回收机制,包括引用类型、各种垃圾收集算法(如标记-清除、复制、标记-整理、分代收集)以及不同垃圾收集器的工作原理和应用场景,特别是CMS和G1收集器的优缺点与使用策略。
摘要由CSDN通过智能技术生成

参考博文及书籍:

1 深入理解Java虚拟机——JVM高级特性与最佳实践(第2版)

https://www.cnblogs.com/aiqiqi/p/10770864.html

3 https://blog.csdn.net/ancientear/article/details/79483592

4 https://blog.csdn.net/qq_26525215/article/details/84294481

写的所有面试题均来自与以前面试的经验、网络上说的可能会出现的面试题。想整理起来留着下次面试用,对于不对的,请多多指正(文章超长预警)

1 JDK、JRE和JVM是什么关系(JDK包含JRE,而JRE包 含JVM

JDK:JDK(Java Development Kit) 是整个JAVA的核心,包括了Java运行环境(Java Runtime Envirnment),一堆Java工具(javac/java/jdb等)和Java基础的类库(即Java API 包括rt.jar)。JDK是java开发工具包,基本上每个学java的人都会先在机器 上装一个JDK,那他都包含哪几部分呢?在目录下面有 六个文件夹、一个src类库源码压缩包、和其他几个声明文件。其中,真正在运行java时起作用的 是以下四个文件夹:bin、include、lib、 jre。有这样一个关系,JDK包含JRE,而JRE包 含JVM。

  1.       bin:最主要的是编译器(javac.exe)
  2.       include:java和JVM交互用的头文件
  3.       lib:类库
  4.       jre:java运行环境

JRE:JRE(Java Runtime Environment,Java运行环境),包含JVM标准实现及Java核心类库(jre里有运行.class的java.exe)。JRE是Java运行环境,并不是一个开发环境,所以没有包含任何开发工具(如编译器和调试器)

总的来说JDK是用于java程序的开发,而jre则是只能运行class而没有编译的功能

JVM:即java虚拟机, java运行时的环境。针对java用户,也就是拥有可运行的.class文件包(jar或者war)的用户。里面主要包含了jvm和java运行时基本类库(rt.jar)。rt.jar可以简单粗暴地理解为:它就是java源码编译成的jar包。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java的能够“一次编译,到处运行”的原因。

2 JVM内存结构

 

其中,线程私有:虚拟机栈、本地方法栈、程序计数器;线程共享:方法区,堆

程序计数器:

 程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完。

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

  程序计数器主要有两个作用

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型。Java虚拟机栈是由一个个栈帧组成,线程在执行一个方法时,便会向栈中放入一个栈帧,每个栈帧中都拥有局部变量表、操作数栈、动态链接、方法出口信息。局部变量表主要存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)和对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

本地方法栈

 本地方法栈和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 

  本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间

方法区:

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

  HotSpot 虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出问题。

  相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了

堆:

堆是Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存

  Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:其中新生代又分为:Eden(伊甸)空间、Survivor From 、Survivor To 空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。“分代回收”是基于这样一个事实:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率。从内存分配的角度来看,线程共享的java堆中可能会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

   

  如图所示,JVM内存主要由新生代、老年代、永久代构成。

  ① 新生代(Young Generation):大多数对象在新生代中被创建,其中很多对象的生命周期很短。每次新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。

  新生代内又分三个区:一个Eden区,两个Survivor区(一般而言),大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到两个Survivor区(中的一个)。当这个Survivor区满时,此区的存活且不满足“晋升”条件的对象将被复制到另外一个Survivor区。对象每经历一次Minor GC,年龄加1,达到“晋升年龄阈值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间,在Serial和ParNew GC两种回收器中,“晋升年龄阈值”通过参数MaxTenuringThreshold设定,默认值为15。

  ② 老年代(Old Generation):在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代,该区域中对象存活率高。老年代的垃圾回收(又称Major GC)通常使用“标记-清除”或“标记-整理”算法。整堆包括新生代和老年代的垃圾回收称为Full GC(HotSpot VM里,除了CMS之外,其它能收集老年代的GC都会同时收集整个GC堆,包括新生代)。

  ③ 永久代(Perm Generation):主要存放元数据,例如Class、Method的元信息,与垃圾回收要回收的Java对象关系不大。相对于新生代和年老代来说,该区域的划分对垃圾回收影响比较小。

  在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。

运行时常量池:

  运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)

  JDK1.7及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

   

直接内存:

  直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。

JVM7和JVM8的区别:

3 OOM(OutOfMemoryError)和StackOverFlowError

OutOfMemoryError:Java heap space。Java虚拟机堆内存不够。

  1. Java虚拟机的堆内存设置不够,可以通过参数-Xms,-Xmx来调整
  2. 代码中创建了大量的对象,并且长时间不能被垃圾收集器收集(不能被垃圾收集器收集的原因是存在被引用)

如下图,java堆用来存储对象实例,只要不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量达到最大堆容量后就会产生内存溢出现场

解决这类问题有两种办法

  1. 检查程序,看是否有死循环或不必要地重复创建大量对象(可以通过-xx:PrintGCDetails输出详细的GC处理日志)。
  2. 增加Java虚拟机中Xms(初始堆大小)和Xmx(最大堆大小)参数的大小(在VM options中就可以填写相应的)

OutOfMemoryError:PermGen space。说明Java虚拟机对永久代Perm内存设置不够,发生这种问题的原意是程序中使用了大量的jar或class,使java虚拟机装载类的空间不够,与Permanent Generation space有关。解决这类问题有以下两种办法:

  1. 增加java虚拟机中的-XX:PermSize和-XX:MaxPermSize参数的大小,其中-XX:PermSize是初始永久保存区域大小,-XX:MaxPermSize是最大永久保存区域大小。
  2. 清理应用程序中web-inf/lib下的jar,如果tomcat部署了多个应用,很多应用都使用了相同的jar,可以将共同的jar移到tomcat共同的lib下,减少类的重复加载。

OutOfMemoryError:unable to create new native thread。这种错误在Java线程个数很多的情况下容易发生

StackOverflowError:

Java运行的时候有一个虚拟机栈,栈中的数据都是以栈帧的格式存在,栈帧是一个内存区块,是一个有关方法和运行期数据数据集,当一个方法A被调用是就产生了一个栈帧F1,并压入栈中,A方法又调用B方法,于是栈帧F2也被压入栈中......,执行完毕后,先弹出F2,再弹出F1,遵循“先进后出,后进先出”的原则。

栈帧中主要保存3类数据:

  1. 本地变量:输入参数和输出参数以及方法内的变量
  2. 栈操作:记录入栈、出栈的操作
  3. 栈帧数据:包括类文件、方法等

原因:函数调用栈太深了,注意代码中是否有了循环调用方法而无法退出的情况。一个虚拟机栈默认最多可以放4个栈帧,如果栈帧超过,就会出现栈溢出。 

出现了方法的循环调用,方法调用超出了最大栈帧,栈溢出。

4 垃圾回收(线程私有区域会随着线程而生、随线程而灭,在线程结束时,内存自然就跟着一起回收了。因此垃圾回收主要关注堆和方法区(元空间))

垃圾回收主要要思考三个问题:

  1. 哪些内存需要回收
  2. 什么时候回收
  3. 如何回收

4.1 对象已死吗?

Java堆中存放着几乎所有的对象实例,垃圾回收器在堆进行垃圾回收前,首先要判断这些对象那些还存活,那些已经“死去”。判断对象是否已“死”有如下几种算法:

4.4.1 引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就加一,当引用失效时,计数器的值就减一。任何时刻计数器值为零的对象就是不可能再被使用的。

这种算法的优点的是实现简单,但是难以解决对象之间相互引用的问题(循环引用)。因此,主流的JAVA虚拟机都没有选用引用计数法来管理内存。

从以上代码可以看出,即使两个互相引用,还是会对他们进行收集,这也从侧面说明主流JAVA虚拟机都不是使用这个算法。

4.1.2 可达性分析算法

所以一般采用“可达性分析”来判断对象是否存活。此算法的核心思想:通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,当一个对象到 GC Roots 没有任何的引用链相连时(从 GC Roots 到这个对象不可达)时,证明此对象不可用。以下图为例:

对象Object5 —Object6之间虽然彼此还有联系,但是它们到 GC Roots 是不可达的,因此它们会被判定为可回收对象。

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

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  2. 方法区中静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中(Native方法)引用的对象

4.1.3 引用

无论是引用计数算法还是可达性分析算法,都提到了一个重要的词“引用”。在JAVA中有四种引用:强引用、软引用、弱引用、虚引用,这四种的引用强度依次递减。

  1. 强引用: 强引用指的是在程序代码之中普遍存在的,类似于"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例。
  2. 软引用: 软引用是用来描述一些还有用但是不是必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。
  3. 弱引用: 弱引用也是用来描述非必需对象的。但是它的强度要弱于软引用。被弱引用关联的对象只能生存到下一次垃圾回收发生之前。当垃圾回收器开始进行工作时,无论当前内容是否够用,都会回收掉只被弱引用关联的对象。在JDK1.2之后提供了WeakReference类来实现弱引用。
  4. 虚引用: 虚引用也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。

即使在可达性分析算法中不可达的对象,也并非"非死不可"的,这时候他们暂时处在"缓刑"阶段。要宣告一个对象的真正死亡,至少要经历两次标记过程: 如果对象在进行可达性分析之后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者finalize()方法已经被JVM调用过,虚拟机会将这两种情况都视为"没有必要执行",此时的对象才是真正"死"的对象。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它(这里所说的执行指的是虚拟机会触发finalize()方法)。finalize()方法是对象逃脱死亡的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象在finalize()中成功拯救自己(只需要重新与引用链上的任何一个对象建立起关联关系即可),那在第二次标记时它将会被移除出"即将回收"的集合;如果对象这时候还是没有逃脱,那基本上它就是真的被回收了。

public class Test {
    public static Test test;
    public void isAlive() {
        System.out.println("I am alive :)");
    }
    @Override   //重写了finalize()方法
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        //重新建立引用活了下来
        test = this;
    }
    public static void main(String[] args)throws Exception {
        test = new Test();
        test = null;
        System.gc();
        Thread.sleep(500);
        //因为重写了finalize()方法且没有执行过,所以还存活了下来
        if (test != null) {
            test.isAlive();
        }else {
            System.out.println("no,I am dead :(");
        }
        // finalize()方法已经执行过了,但是此次自救失败
        test = null;
        System.gc();
        Thread.sleep(500);
        if (test != null) {
            test.isAlive();
        }else {
            System.out.println("no,I am dead :(");
        }
    }

}

从上面代码示例我们发现,finalize方法确实被JVM触发,并且对象在被收集前成功逃脱。但是对于第二次,一个对象的finalize()方法都只会被系统自动调用一次,如果相同的对象在逃脱一次后又面临一次回收,它的finalize()方法不会被再次执行,被回收。

4.2 垃圾回收算法

4.2.1 标记-清除算法

算法思想:“标记”和“清除”两个过程,首先标记所需要回收的对象,在标记完后统一回收所有被标记的对象。

标记-清除算法存在两个不足

  1. 效率问题:“标记”和“清除”两个过程的效率都不高
  2. 空间问题:标记清除之后会产生大量的不连续的空间碎片。空间碎片可能导致以后在程序运行的过程中需要分配较大对象时,无法找到连续内存而不得不提前出发另一次垃圾收集动作。

标记-清除算法示意图

4.2.2 复制算法(新生代回收算法)

“复制”算法是为了解决“标记-清除”的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等的复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。

复制算法示意图

现在的商用虚拟机(包括HotSpot)都是采用这种收集算法来回收新生代

新生代中98%的对象都是"朝生夕死"的,所以并不需要按照1 : 1的比例来划分内存空间,而是将内存(新生代内存)分为一块较大的Eden(伊甸园)空间和两块较小的Survivor(幸存者)空间,每次使用Eden和其中一块Survivor(两个Survivor区域一个称为From区,另一个称为To区域)。当回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

HotSpot默认Eden与Survivor的大小比例是8 : 1,也就是说Eden:Survivor From : Survivor To = 8:1:1。所以每次新生代可用内存空间为整个新生代容量的90%,而剩下的10%用来存放回收后存活的对象。

HotSpot实现的复制算法流程如下:

当Eden区满的时候,会触发第一次Minor gc,把还活着的对象拷贝到Survivor From区;当Eden区再次满触发Minor gc的时候,会扫描Eden区和From区,对两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域,并将Eden区和From区清空。此时From和To进行互换。

当后续Eden区又发生Minor gc的时候,会对Eden区和新的From区进行垃圾回收,存活的对象复制到新的To区,并将Eden区和From区清空

部分对象会在From区域和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还存活,就存入老年代。

4.2.3 标记-整理算法(老年代回收算法)

复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。针对老年代的特点,提出了一种称之为“标记-整理算法”。标记-整理算法的标记过程与标记-清除算法一致,但是后续不是直接对可回收对象进行清除,而是让所有存活对象都像一端移动,然后直接清理掉边界以外的内存。

标记-整理算法示意图

4.2.4 分代算法

把Java对分成新生代和老年代分别进行收集。新生代每次都有大批的对象死去,使用复制算法。老年代中对象的存活率较高,使用标记-整理或者标记-清除算法。

4.3 垃圾收集器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。目前HotSpot虚拟机用到的垃圾回收器如下图所示。注意只有两个回收器之间有连线才能配合使用。

4.3.1 Serial收集器

Serial收集器是最基本、发展历史最悠久的收集器,是一个单线程收集器,它不仅只会使用一个CPU或一条工作线程去完成垃圾收集工作,更重要的是,在运行该垃圾收集器时,必须暂停其他所有工作线程,直到它收集结束(Stop The World,STW)。

它适合Client模式的应用,在单CPU环境下,它简单高效,由于没有线程交互的开销,专心垃圾收集自然可以获得最高的单线程效率。

        串行的垃圾收集器有两种,Serial与Serial Old,一般两者搭配使用。新生代采用Serial,是利用复制算法;老年代使用Serial Old采用标记-整理算法。Client应用或者命令行程序可以,通过-XX:+UseSerialGC可以开启上述回收模式。下图是其运行过程示意图。
 

Serial/Serial Old运行过程示意图。

新生代:Serial,复制算法

老年代:Serial Old,标记-整理算法。

4.3.2 ParNew收集器

ParNew收集器是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余的和Serial收集器完全一致,也会出现STW的现象。默认开启的收集线程数和cpu数量一样,运行数量可以通过修改ParallelGCThreads设定。用于新生代收集,复制算法。使用-XX:+UseParNewGC,和Serial Old收集器组合进行内存回收。除了Serial收集器外,目前只有它能与CMS收集器配合工作。

ParNew/Serial Old运行过程示意图

4.3.3 Parallel Scavenge收集器(吞吐量优先收集器)

Parallel Scavenge收集器是一个使用复制算法的并行多线程新生代收集器。它与其他算法不同的是,其他算法都是尽量缩短垃圾收集时用户线程的停顿时间(即STW时间),这个垃圾收集器的目标则是达到一个可控制的吞吐量。

吞吐量=代码运行时间/(代码运行时间+垃圾收集时间),也就是高效率利用cpu时间。提供了两个参数用于精确控制吞吐量,最大停顿时间MaxGCPauseMillis、吞吐量大小GCTimeRatio。

MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽可能的保证内存回收的花费时间不超过设定值。GCTimeRatio是一个大于0而小于100的数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。如果此参数值设置为19,那么吞吐量为95%=1/(1+19)。

如果设置了-XX:+UseAdaptiveSizePolicy参数,则随着GC,会动态调整新生代的大小,Eden,Survivor比例等,以提供最合适的停顿时间或者最大的吞吐量。用于新生代收集,复制算法。通过-XX:+UseParallelGC参数,Server模式下默认提供了其和SerialOld进行搭配的分代收集方式。

Parallel Old是Parallel Scavenge的老年代版本,使用的是标记-整理算法。

Parallel Scavenge/Parallel Old运行过程示意图

4.3.3 CMS(Concurrent Mark Sweep)收集器

CMS收集器是一种以获得最短回收停顿时间为目标的收集器。从名字就能知道这是利用标记-清除算法的,目前很大一部分应用集中在互联网站和B/S系统的服务器上,系统停顿时间短,能给用户带来较好的体验。但是它比一般的标记-清除算法要复杂一些,分为以下4个阶段:

       初始标记:标记一下GC Roots能直接关联到的对象,会“Stop The World”。

       并发标记:GC Roots Tracing(可达性分析,引用链),可以和用户线程并发执行。

       重新标记:修正并发标记期间因用户程序继续运作而导致标志产生变动的那一部分对象的标记记录,执行时间相对并发标记短,会“Stop The World”。

       并发清除:清除对象,可以和用户线程并发执行。

由于耗时最长的并发标记和并发清除过程收集器线程都可以和用户线程一起工作,所以总体来说,CMS收集器的内存回收过程就是和用户线程一起并发执行的。CMS收集器有以下三个缺点:

  1. 对CPU资源敏感。面向并发设计的程序都对CPU资源比较敏感(并发程序的特点)。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS的默认收集线程数量是=(CPU数量+3)/4; 当CPU数量越多,回收的线程占用CPU就少。也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。(比如 CPU=2时,那么就启动一个线程回收,占了50%的CPU资源。)(一个回收线程会在回收期间一直占用CPU资源)

  2. 无法处理浮动垃圾。无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败,在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;也可以认为CMS所需要的空间比其他垃圾收集器大; 可以使用"-XX:CMSInitiatingOccupancyFraction",设置CMS预留老年代内存空间; 
  3. 产生大量内存碎片。由于CMS是基于“标记+清除”算法来回收老年代对象的,因此长时间运行后会产生大量的空间碎片问题,可能导致新生代对象晋升到老生代失败。由于碎片过多,将会给大对象的分配带来麻烦。因此会出现这样的情况,老年代还有很多剩余的空间,但是找不到连续的空间来分配当前对象,这样不得不提前触发一次Full GC。一般CMS收集器与参数"-XX:+UseCMSCompactAtFullCollection"和"-XX:+CMSFullGCsBeforeCompaction"结合使用。
  • UseCMSCompactAtFullCollection:为了解决空间碎片问题,CMS收集器提供−XX:+UseCMSCompactAlFullCollection标志,使得CMS出现上面这种情况时不进行Full GC,而开启内存碎片的合并整理过程;但合并整理过程无法并发,停顿时间会变长;这个开关是默认开启的。
  • CMSFullGCsBeforeCompaction。由于合并整理是无法并发执行的,空间碎片问题没有了,但是有导致了连续的停顿。因此,可以使用另一个参数−XX:CMSFullGCsBeforeCompaction,表示在多少次不压缩的Full GC之后,对空间碎片进行压缩整理。可以减少合并整理过程的停顿时间;默认为0,也就是说每次都执行Full GC,不会进行压缩整理;

4.3.5 G1收集器

上一代的垃圾收集器(串行serial, 并行parallel, 以及CMS)都把堆内存划分为固定大小的三个部分: 年轻代(young generation), 年老代(old generation), 以及持久代(permanent generation)。

G1(Garbage-First)是JDK7-u4才推出商用的收集器;G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。被视为JDK1.7中HotSpot虚拟机的一个重要进化特征。G1的使命是在未来替换CMS,并且在JDK1.9已经成为默认的收集器。

特点

  1. 并行与并发。G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
  2. 分代收集。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;能够采用不同方式处理不同时期的对象;虽然保留分代概念,但Java堆的内存布局有很大差别;将整个堆划分为多个大小相等的独立区域(Region);新生代和老年代不再是物理隔离,它们都是一部分Region(不需要连续)的集合;
  3. 空间整合。与CMS的“标记–清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。从整体看,是基于标记-整理算法;从局部(两个Region间)看,是基于复制算法;这是一种类似火车算法的实现;不会产生内存碎片,有利于长时间运行;
  4. 可预测的停顿。这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型。可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒。在低停顿的同时实现高吞吐量。

问题

  • 为什么G1可以实现可预测停顿(可预测停顿的意思是可以知道垃圾收集时STW的时间);

可以有计划地避免在Java堆的进行全区域的垃圾收集,G1收集器将内存分大小相等的独立区域(Region),新生代和老年代概念保留,但是已经不再物理隔离。G1跟踪各个Region获得其收集价值大小,在后台维护一个优先列表;每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来);这就保证了在有限的时间内可以获取尽可能高的收集效率;

  • 一个对象被不同区域引用的问题:一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?

在其他的分代收集器,也存在这样的问题(而G1更突出):回收新生代也不得不同时扫描老年代?这样的话会降低Minor GC的效率;无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:每个Region都有一个对应的Remembered Set;每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作;然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。

应用场景:面向服务端应用,针对具有大内存、多处理器的机器;最主要的应用是为需要低GC延迟,并具有大堆的应用程序提供解决方案;

可以通过下面的参数,来设置一些G1相关的配置。

  • 指定使用G1收集器:"-XX:+UseG1GC"
  • 当整个Java堆的占用率达到参数值时,开始并发标记阶段;默认为45:"-XX:InitiatingHeapOccupancyPercent"
  • 为G1设置暂停时间目标,默认值为200毫秒:"-XX:MaxGCPauseMillis"
  • 设置每个Region大小,范围1MB到32MB;目标是在最小Java堆时可以拥有约2048个Region:"-XX:G1HeapRegionSize"
  • 新生代最小值,默认值5%:"-XX:G1NewSizePercent"
  • 新生代最大值,默认值60%:"-XX:G1MaxNewSizePercent"
  • 设置STW期间,并行GC线程数:"-XX:ParallelGCThreads"
  • 设置并发标记阶段,并行执行的线程数:"-XX:ConcGCThreads"

不计算维护Remembered Set的操作,可以分为4个步骤(与CMS较为相似)。

  1. 初始标记(Initial Marking):仅标记一下GC Roots能直接关联到的对象;且修改TAMS(Next Top at Mark Start),让下一阶段并发运行时,用户程序能在正确可用的Region中创建新对象;需要"Stop The World",但速度很快;
  2. 并发标记(Concurrent Marking):从GC Roots开始进行可达性分析,找出存活对象,耗时长,可与用户线程并发执行,并不能保证可以标记出所有的存活对象;(在分析过程中会产生新的存活对象)
  3. 最终标记(Final Marking):修正并发标记阶段因用户线程继续运行而导致标记发生变化的那部分对象的标记记录。上一阶段对象的变化记录在线程的Remembered Set Log;这里把Remembered Set Log合并到Remembered Set中;需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;G1采用多线程并行执行来提升效率;且采用了比CMS更快的初始快照算法:Snapshot-At-The-Beginning (SATB)。
  4. 筛选回收(Live Data Counting and Evacuation):首先排序各个Region的回收价值和成本;然后根据用户期望的GC停顿时间来制定回收计划;最后按计划回收一些价值高的Region中垃圾对象;回收时采用"复制"算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;可以并发进行,降低停顿时间,并增加吞吐量。

3 类加载过程

4 对象创建过程

5 对象的内存布局

6 。。。。。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值