Java基础----JVM详解

说明

本文主要参考自一下文章,包含其中内容的转载,在此表示感谢:

  

概述

介绍JVM相关的组成和结构

前言

JVM(Java虚拟机)是一个抽象机器。 它是一个提供可以执行Java字节码的运行时环境的规范。JVM可用于许多硬件和软件平台(即JVM是平台相关的)。

1. JVM基础概念

1.1 JVM定义

JVM是:

  • 指定Java虚拟机的工作的规范。 但实现提供程序是独立的选择算法。 其实现是由Sun和其他公司提供。
  • 它的实现被称为JRE(Java运行时环境)。
  • 运行时实例只要在命令提示符上编写java命令来运行java类,就会创建JVM的实例。

1.2 JVM 主要组成部分及其作用

组件
说明
作用
类加载器
(ClassLoader)
JVM的一个子系统用于加载类文件
运行时数据区
(Runtime Data Area)
内存区域,存储了很多数据,包括:
  方法区
  虚拟机栈
  本地方法栈
  
  程序计数器
把字节码加载到内存中
执行引擎
(Execution Engine)
包含了:
  虚拟处理器解释器:读取字节码流,然后执行指令。
  即时(JIT)编译器:它用于提高性能,JIT编译的同时有类似字节代码部分的功能,从而减少编译所需的时间。
  编译器是指从Java虚拟机(JVM)的指令集到特定CPU的指令集的转换器。
将字节码翻译成底层系统指令
本地库接口
(Native Interface)
程序执行中需要访问本地方法库,通过本地库接口实现JAVA程序访问本地方法库的窗口

组件的作用

  • 首先通过类加载器会把 Java 代码转换成字节码
  • 运行时数据区再把字节码加载到内存中
  • 而字节码文件只是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器执行引擎,将字节码翻译成底层系统指令,再交由 CPU 去执行
  • 而这个过程中需要调用其他语言的本地库接口来实现整个程序的功能。
    在这里插入图片描述
1.2.1 JVM运行时数据区


查看运行时数据区

在这里插入图片描述

上面的图中看到的是JVM中栈有两个,但是堆只有一个,每一个线程都有自已的线程栈(即虚拟机栈)【线程栈的大小可以通过设置JVM的-xss参数进行配置,32位系统下,JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K】,线程栈里面的数据属于该线程私有,但是所有的线程都共享一个堆空间,堆中存放的是对象数据(什么是对象数据?排除法,排除基本类型以及引用类型以外的数据都将放在堆空间中。其中方法区和堆是所有线程共享的数据区。

在这里插入图片描述

组件共享/私有
说明
Java堆共享Java虚拟机管理的内存中最大的一块,在虚拟机启动时创建,所有线程共享。
这个区域是用来存放对象实例的,几乎所有的对象实例和数组都在这里分配内存。
堆是Java垃圾收集器管理的主要区域(GC堆),垃圾收集器实现了对象的自动销毁
堆内存划分新生代(新生代又分为Eden80%,Survivor20%)老年代(Old),并且一般新生代的空间比老年代大。
Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。
可以通过-Xmx和-Xms控制
方法区共享方法区也叫永久代。
在过去(自定义类加载器还不是很常见的时候),类大多是”static”的,很少被卸载或收集,因此被称为“永久的(Permanent)”。
实际也是堆。
Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java 堆区分开来.
它用于存储程序中永远不变或唯一的内容:已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等。
但是已经被最新的 JVM 取消了。现在,被加载的类作为元数据加载到底层操作系统的本地内存区
详细参考:JVM内存堆布局图解分析5.方法区
虚拟机栈私有用于执行Java方法,栈帧存储局部变量表,操作数栈,动态链接,方法返回地址和一些额外的符加信息。
Java虚拟机栈也是线程私有的,虚拟机栈描述的是Java方法执行的内存模型:
  每个方法执行的时候都会创建一个栈帧,用于存放局部变量表,操作数栈,动态链接,方法出口等信息
程序执行时入栈;执行完成后栈帧出栈。
本地方法栈私有它包含应用程序中使用的所有本地方法。【这些本地方法是由其他语言编写的】
程序计数器私有指示当前程序执行到了哪一行,执行Java方法时记录正在执行的虚拟机字节码指令地址;执行本地方法时,计数器值为null

我们平时把内存分为堆内存和栈内存,其中的栈内存就指的是虚拟机栈的局部变量表部分。着重说一下虚拟机栈中的局部变量表,里面存放了编译期可以知道的三个信息

  • 各种基本数据类型:boolean、byte、char、short、int、float、long、double)
  • 对象引用(reference): 可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置
  • returnAddress地址:返回后所指向的字节码的地址

局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。当递归层次太深时,会引发java.lang.StackOverflowError,这是虚拟机栈抛出的异常。

这个returnAddress和程序计数器有什么区别

  • 前者是指示JVM的指令执行到哪一行,后者则是你的代码执行到哪一行。

对于私有内存区:伴随线程的产生而产生,一旦线程终止,私有内存区也会自动消除
有的区域随着虚拟机进程的启动而存在,有的区域则依赖用户进程的启动和结束而创建和销毁。

1.3 JVM作用

  • 加载代码
  • 验证代码
  • 执行代码
  • 提供运行时环境

2. 类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立在 JVM 中的唯一性,每一个类加载器,都有一个独立的类名称空间。类加载器就是根据指定全限定名称将 class 文件加载到 JVM 内存,然后再转化为 class 对象。

2.1 类加载器分类

名称说明
启动类加载器
(Bootstrap ClassLoader)
是虚拟机自身的一部分,用来加载Java_HOME/lib/目录中的,
或者被 -Xbootclasspath 参数所指定的路径中并且被虚拟机识别的类库;
扩展类加载器
(Extension ClassLoader)
负责加载\lib\ext目录或Java. ext. dirs系统变量指定的路径中的所有类库;
应用程序类加载器
(Application ClassLoader)
负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。
一般情况,如果我们没有自定义类加载器默认就是用这个加载器。
自定义类加载器-

2.2 类加载的执行过程

类加载分为以下 5 个步骤:

  1. 加载:根据查找路径找到相应的 class 文件然后导入;
  2. 检查:检查加载的 class 文件的正确性;
  3. 准备:给类中的静态变量分配内存空间;
  4. 解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标示,而在直接引用直接指向内存中的地址;
  5. 初始化:对静态变量和静态代码块执行初始化工作。

2.3 双亲委派模型

双亲委派模型:

如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。

3. JVM 垃圾回收机制(GC)

与C/C++相比,JAVA并不要求我们去人为编写代码进行内存回收和垃圾清理。JAVA提供了垃圾回收器(garbage collector)来自动检测对象的作用域),可自动把不再被使用的存储空间释放掉,也就是说,GC机制可以有效地防止内存泄露以及内存溢出。

3.1 确定垃圾回收的对象

哪些内存需要回收是垃圾回收机制第一个要考虑的问题,所谓“要回收的垃圾”无非就是那些不可能再被任何途径使用的对象

那么如何找到这些对象?

3.1.1 引用计数法

这个算法的实现是,为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。

这种算法使用场景很多,但是,Java中却没有使用这种算法,它有一个缺点不能解决循环引用(如对象之间相互引用)的问题:
  例如A、B相互引用,A销毁的前提是B销毁,然后引用计数减一;而B销毁的前提是A销毁,然后引用计数减一。我们就无法选出任何一个去销毁。

3.1.2 可达性分析法

这个算法的基本思想是通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(即GC Roots到对象不可达)时,则证明此对象是不可用的。

那么问题又来了,如何选取GCRoots对象呢?在Java语言中,可以作为GCRoots的对象包括下面几种:

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

下面给出一个GCRoots的例子,如下图,为GCRoots的引用链。
在这里插入图片描述
由图可知,obj8、obj9、obj10都没有到GCRoots对象的引用链,即便obj9和obj10之间有引用链,他们还是会被当成垃圾处理,可以进行回收。

具体例子:

public class Test{
  public static void main(String[] a){
     Integer n1=new Integer(9);
     Integer n2=new Integer(3);
     n2=n1;
     // other codes
  }
}

在这里插入图片描述
如上图所示,垃圾回收器在遍历有向图时,资源2所占的内存不可达,垃圾回收器就会回收该块内存空间。

3.1.3 四种引用状态

在JDK1.2之前,Java中引用的定义很传统:如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过于狭隘,一个对象只有被引用或者没被引用两种状态。我们希望描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用软引用弱引用虚引用4种,这4种引用强度依次减弱。

引用类别
说明
强引用代码中普遍存在的类似"Object obj = new Object()"这类的引用。
只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用描述有些还有用但并非必需的对象。
在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行二次回收
如果这次回收还没有足够的内存,才会抛出内存溢出异常。
Java中的类SoftReference表示软引用。
弱引用描述非必需对象。
被弱引用关联的对象只能生存到下一次垃圾回收之前,垃圾收集器工作之后,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
Java中的类WeakReference表示弱引用。
虚引用这个引用存在的唯一目的就是在这个对象被收集器回收时收到一个系统通知,被虚引用关联的对象,和其生存时间完全没关系。
Java中的类PhantomReference表示虚引用。

重点如图:
在这里插入图片描述

3.1.4 两次标记阶段

对于可达性分析算法而言,未到达的对象并非是“非死不可”的,若要宣判一个对象死亡,至少需要经历两次标记阶段。

  1. 如果对象在进行可达性分析后发现没有与GCRoots相连的引用链,则该对象被第一次标记并进行一次筛选,筛选条件为是否有必要执行该对象的finalize方法:
    1. 若对象没有覆盖finalize方法或者该finalize方法是否已经被虚拟机执行过了,则均视作不必要执行该对象的finalize方法,即该对象将会被回收。
    2. 反之,若对象覆盖了finalize方法并且该finalize方法并没有被执行过,那么,这个对象会被放置在一个叫F-Queue的队列中,之后会由虚拟机自动建立的、优先级低的Finalizer线程去执行,而虚拟机不必要等待该线程执行结束,即虚拟机只负责建立线程,其他的事情交给此线程去处理。
  2. 对F-Queue中对象进行第二次标记
    1. 如果对象在finalize方法中拯救了自己,即关联上了GCRoots引用链,如把this关键字赋值给其他变量,那么在第二次标记的时候该对象将从“即将回收”的集合中移除
    2. 如果对象还是没有拯救自己,那就会被回收。如下代码演示了一个对象如何在finalize方法中拯救了自己,然而,它只能拯救自己一次,第二次就被回收了。
package com.demo;

/*
 * 此代码演示了两点:
 * 1.对象可以再被GC时自我拯救
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 * */
public class FinalizeEscapeGC {
    
    public String name;
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public FinalizeEscapeGC(String name) {
        this.name = name;
    }

    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }
    
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        System.out.println(this);
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    @Override
    public String toString() {
        return name;
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC("leesf");
        System.out.println(SAVE_HOOK);
        // 对象第一次拯救自己
        SAVE_HOOK = null;
        System.out.println(SAVE_HOOK);
        System.gc();
        // 因为finalize方法优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead : (");
        }

        // 下面这段代码与上面的完全相同,但是这一次自救却失败了
        // 一个对象的finalize方法只会被调用一次
        SAVE_HOOK = null;
        System.gc();
        // 因为finalize方法优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead : (");
        }
    }
}

运行结果如下:

leesf
null
finalize method executed!
leesf
yes, i am still alive :)
no, i am dead : (

由结果可知,该对象拯救了自己一次,第二次没有拯救成功,因为对象的finalize方法最多被虚拟机调用一次。此外,从结果我们可以得知,一个堆对象的this(放在局部变量表中的第一项)引用会永远存在,在方法体内可以将this引用赋值给其他变量,这样堆中对象就可以被其他变量所引用,即不会被回收。

3.2 JVM垃圾回收算法

既然确定了JVM垃圾回收的对象,那么我盟应该采用何种算法来回收他们呢?
这里,我们会介绍四种垃圾回收算法:

算法说明优点缺点
标记-清除算法先标记,再统一回收简单效率低,容易产生内存碎片,导致大对象无法存储
复制算法内存一分为二,一半使用,一半用于复制存活对象解决标记-清除算法效率低下的问题,减少内存碎片内存利用率只有一半
标记-整理算法先标记,然后让存活对象向一端移动,最后统一回收既解决了复制算法内存利用率低的问题,又减少了标记-清除算法空间碎片的产生增加了存活对象移动的开销
分代算法根据对象生命周期对划分内存,分别采用合适的算法回收效率高,内存使用率也高内部实现较为复杂,需要合理划分内存
3.2.1 标记-清除算法(Mark-Sweep)

定义:这是最基础的算法,如名,分为“标记”和“清除”两个阶段–首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。

缺点:这种算法的不足主要体现在效率和空间:

  • 从效率的角度讲,标记和清除两个过程的效率都不高
  • 从空间的角度讲,标记清除后会产生大量不连续的内存碎片内存碎片太多可能会导致以后程序运行过程中在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作

标记-清除算法执行过程
在这里插入图片描述

3.2.2 复制算法(Copying)

定义复制算法是为了解决效率问题而出现的。它将可用的内存分为两块,每次只用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。
优点:这样每次只需要对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。
缺点内存缩小为了原来的一半,这样代价太高了

现在的商用虚拟机都采用这种算法来回收新生代,不过研究表明1:1的比例非常不科学,因此新生代的内存被划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。关于回收,后面会讲到。

复制算法的执行过程
在这里插入图片描述

3.2.3 标记-整理算法(Mark-Compact)

复制算法在对象存活率较高的场景下要进行大量的复制操作,效率很低。万一对象100%存活,那么需要有额外的空间进行分配担保。老年代都是不易被回收的对象,对象存活率高,因此一般不能直接选用复制算法。根据老年代的特点,有人提出了另外一种标记-整理算法

定义:过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。
优点:既解决了复制算法内存利用率低的问题,又减少了标记-清除算法空间碎片的产生
缺点:增加了存活对象移动的开销。
标记-整理算法的工作过程
在这里插入图片描述

3.2.4 分代收集算法

先概括下堆内存的布局:
在这里插入图片描述

现代商用虚拟机基本都采用分代收集算法来进行垃圾回收。
这种算法没什么特别的,无非是上面内容的结合罢了。

定义:根据对象的生命周期的不同将内存划分为几块,然后根据各块的特点采用最适当的收集算法。
优点:回收效率高,内存使用率也高。
缺点:内部实现较为复杂,需要合理划分内存。
分代收集算法执行过程
  - 大批对象死去、少量对象存活的(新生代),使用复制算法,复制成本低
  - 对象存活率高、没有额外空间进行分配担保的(老年代),采用标记-清理算法或者标记-整理算法

3.3 垃圾收集器

垃圾收集器就是上面讲的理论知识的具体实现了。不同虚拟机所提供的垃圾收集器可能会有很大差别,我们使用的是HotSpot,HotSpot这个虚拟机所包含的所有收集器如图:
在这里插入图片描述
上图展示了7种作用于不同分代的收集器:

  • 如果两个收集器之间存在连线,那说明它们可以搭配使用。
  • 虚拟机所处的区域说明它是属于新生代收集器还是老年代收集器。
3.3.0 垃圾回收器分类归纳

按代划分:

回收器类型包含说明
新生代回收器Serial、ParNew、Parallel Scavenge一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低
老年代回收器Serial Old、Parallel Old、CMS一般采用的是标记-整理的算法进行垃圾回收
整堆回收器G1-

按运行方式划分

回收器类型
概述
年轻代老年代
串行回收器客户端模式的默认回收器。
所谓的串行,指的就是单线程回收,回收时将会暂停所有应用线程的执行
Serial收集器Serial Old回收器
并行回收器服务器模式的默认回收器。
利用多个线程进行垃圾回收,充分利用CPU,回收期间暂停所有应用线程
Parallel Scavenge回收器
(ParNew也是多线程)
parrellel old回收器
CMS回收器停顿时间最短,分为以下步骤:1初始标记;2并发标记;3重新标记;4并发清除。
优点是停顿时间短,并发回收,缺点是无法处理浮动垃圾,而且会导致空间碎片产生
X适用
G1回收器新技术,将堆内存划分为多个等大的区域,按照每个区域进行回收。
工作过程是1初始标记;2并发标记;3最终标记;4筛选回收。
特点是并行并发,分代收集,不会导致空间碎片,也可以由编程者自主确定停顿时间上限
适用适用

多说一句,我们必须明确一个观点:没有最好的垃圾收集器,更加没有万能的收集器,只能选择对具体应用最合适的收集器。这也是HotSpot为什么要实现这么多收集器的原因。OK,下面一个一个看一下收集器。

3.3.1 Serial收集器

定位:最基本、发展历史最久的收集器。Serial收集器依然是虚拟机运行在Client模式下的默认新生代收集器
定义:这个收集器是一个采用复制算法的单线程的收集器
  - 单线程一方面意味着它只会使用一个CPU或一条线程去完成垃圾收集工作
  - 另一方面也意味着它进行垃圾收集时必须暂停其他线程的所有工作,直到它收集结束为止。
优点:简单高效。对于单个CPU环境而言,Serial收集器由于没有线程交互开销,可以获取最高的单线程收集效率。
缺点:需要STW(Stop The World),停顿时间长。单线程意味着回收时,在用户不可见的情况下要把用户正常工作的线程全部停掉,这对很多应用是难以接受的。

不过实际上到目前为止,Serial收集器依然是虚拟机运行在Client模式下的默认新生代收集器,因为它简单而高效。用户桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代停顿时间在几十毫秒最多一百毫秒,只要不是频繁发生,这点停顿是完全可以接受的。

Serial收集器运行过程

在这里插入图片描述

3.3.2 ParNew收集器

定位:ParNew收集器其实就是Serial收集器的多线程版本。
定义:除了使用多条线程进行垃圾收集外,其余行为和Serial收集器完全一样,包括使用的也是复制算法。
优点:ParNew收集器除了多线程以外和Serial收集器并没有太多创新的地方,但是它却是Server模式下的虚拟机首选的新生代收集器,其中有一个很重要的和性能无关的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作(看图)

ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于线程交互的开销,该收集器在两个CPU的环境中都不能百分之百保证可以超越Serial收集器。当然,随着可用CPU数量的增加,它对于GC时系统资源的有效利用还是很有好处的。

缺点:作为Serial的多线程版本,同样需要停掉用户正常工作的线程。
设置项它默认开启的收集线程数与CPU数量相同,在CPU数量非常多的情况下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数
ParNew收集器运行过程
在这里插入图片描述

3.3.3 Parallel Scavenge收集器

(Scavenge : 清道夫)
定位:一个关注吞吐量的多线程收集器,因此也被称为“吞吐量优先收集器”。它是虚拟机运行在Server模式下的默认垃圾收集器。

CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量

所谓吞吐量的意思就是CPU用于运行用户代码时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总运行100分钟,垃圾收集1分钟,那吞吐量就是99%。

定义:Parallel Scavenge收集器也是一个新生代收集器,也是用复制算法的收集器,也是并行的多线程收集器。
优点:可以控制吞吐量、停顿时间。

低吞吐量意味着停顿时间短,适合需要与用户交互的程序,良好的响应速度能提升用户体验;高吞吐量意味着停顿时间长,但可以高效率利用CPU时间,尽快完成运算任务,主要适合在后台运算而不需要太多交互的任务。

缺点:设置较为复杂,并且需要协调好停顿时间与吞吐量大小关系。
设置项:虚拟机提供了-XX:MaxGCPauseMillis-XX:GCTimeRatio两个参数来精确控制最大垃圾收集停顿时间和吞吐量大小。

Parallel Scavenge收集器还有一个-XX:+UseAdaptiveSizePolicy参数,这是一个开关参数,这个参数打开之后,就不需要手动指定新生代大小、Eden区和Survivor参数等细节参数了,虚拟机会根据当前系统的运行情况手机性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

如果对于垃圾收集器运作原理不太了解,以至于在优化比较困难的时候,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。

3.3.4 Serial Old收集器

定位:Serial收集器的老年代版本。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。
定义:一个单线程收集器,使用“标记-整理算法”

优缺点、执行过程同Serial收集器

3.3.5 Parallel Old收集器

定位:Parallel Scavenge收集器的老年代版本。
定义:一个多线程收集器,使用“标记-整理”算法。
优点:可以控制吞吐量、停顿时间。

这个收集器在JDK 1.6之后的出现,“吞吐量优先收集器”终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge收集器+Parallel Old收集器的组合。

Parallel Old收集器运行过程
在这里插入图片描述

3.3.6 CMS收集器

定位:以获取最短回收停顿时间为目标的收集器。
定义:使用标记 - 清除算法。
优点:停顿啊时间短。
缺点:由于采用了标记-清除算法,缺点主要集中在资源利用率和收集效率上。

  • 对CPU资源非常敏感,可能会导致应用程序变慢,吞吐率下降。
  • 无法处理浮动垃圾,因为在并发清理阶段用户线程还在运行,自然就会产生新的垃圾,而在此次收集中无法收集他们,只能留到下次收集,这部分垃圾为浮动垃圾.
  • 同时,由于用户线程并发执行,所以需要预留一部分老年代空间提供并发收集时程序运行使用
  • 由于采用的标记 - 清除算法,会产生大量的内存碎片,不利于大对象的分配,可能会提前触发一次Full GC。

设置项

  • 虚拟机提供了-XX:+UseCMSCompactAtFullCollection参数来进行碎片的合并整理过程,这样会使得停顿时间变长。
  • 虚拟机还提供了一个参数配置,-XX:+CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩的Full GC后,接着来一次带压缩的GC。

CMS收集器执行过程

  • 初始标记,标记GCRoots能直接关联到的对象,时间很短。
  • 并发标记,进行GCRoots Tracing(可达性分析)过程,时间很长。
  • 重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,时间较长。
  • 并发清除,回收内存空间,时间很长。

其中,并发标记与并发清除两个阶段耗时最长,但是可以与用户线程并发执行。
在这里插入图片描述

3.3.7 G1收集器

定位:G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。
定义:将堆内存划分为多个等大的区域,按照每个区域进行回收。
特点:与其他GC收集器相比,G1收集器有以下特点:

  • 并行和并发。使用多个CPU来缩短Stop The World停顿时间,与用户线程并发执行。
  • 分代收集。独立管理整个堆,但是能够采用不同的方式去处理新创建对象和已经存活了一段时间、熬过多次GC的旧对象,以获取更好的收集效果。
  • 空间整合。基于标记 - 整理算法,无内存碎片产生。
  • 可预测的停顿。能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

优点:并行并发,分代收集,不会导致空间碎片,也可以由编程者自主确定停顿时间上限
G1回收器执行过程:1初始标记;2并发标记;3最终标记;4筛选回收。

在G1之前的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分(可以不连续)Region的集合。

3.4 分代垃圾回收

3.4.1 为什么要分代

分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。

在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

试想,在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长。同时,因为每次回收都需要遍历所有存活对象,但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是他们依旧存在。

因此,分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。

3.4.2 如何分代

在这里插入图片描述
如图所示,虚拟机中的共划分为三个代:年轻代(Young Generation)、年老代(Old Generation)和持久代(Permanent Generation)。
其中持久代主要存放的是Java类的类信息,与垃圾收集要收集的Java对象关系不大。年轻代和年老代的划分是对垃圾收集影响比较大的。
新生代:刚刚新建的对象被放在Eden中。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1。关于Survivor,参考为什么新生代内存需要有两个Survivor区以及下文的分代垃圾回收器。

需要注意,Survivor的两个区是对称的,没先后关系。所以同一个区中可能同时存在从Eden复制过来对象和从前一个Survivor复制过来的对象。而复制到年老区的只有从第一个Survivor区过来的对象。而且,Survivor区总有一个是空的。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。

老生代:如果某个对象经历了几次垃圾回收之后还存活,就会被存放到老年代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。老年代的空间一般比新生代大。

持久代:持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过-XX:MaxPermSize=进行设置(jdk1.8后被元空间取代)。

GC分类

GC分类

GC名称
说明
Minor GC用于清理新生代(Eden)区域,频率高,速度快(大部分对象活不过一次Minor GC)。
Eden区满了就会触发一次Minor GC。
Major GC用于清理老年代区域,速度慢
Full GC清理整个堆空间,成本较高,会对系统性能产生影响。
垃圾回收触发条件

// 不过实际运行中,Major GC会伴随至少一次 Minor GC,因此也不必过多纠结于到底是哪种GC。

由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:Young GC(Minor GC)和Full GC(Major GC)。
针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:

GC 类别
说明
Partial GC并不收集整个GC堆的模式。包括的种类有:
  Young GC:只收集young gen的GC
  Old GC:只收集old gen的GC。只有CMS的concurrent collection是这个模式
  Mixed GC:收集整个young gen以及部分old gen的GC。只有G1有这个模式
Full GC收集整个堆,包括young gen、old gen、perm gen(如果存在的话)等所有部分的模式。

Young GC

一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Young GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。

然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。

因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。

注意young GC中有部分存活对象会晋升到old gen,所以young GC后old gen的占用量通常会有所升高。

新生代通常存活时间较短通常基于Copying算法进行回收,所谓Copying算法就是扫描出存活的对象,并复制到一块新的完全未使用的空间中,对应于新生代,就是在Eden和FromSpace或ToSpace之间copy

新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。当连续分配对象时,对象会逐渐从Eden到Survivor,最后到老年代。

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

在执行机制上JVM提供了串行GC(Serial MSC)、并行GC(Parallel MSC)和并发标记清理垃圾回收(Concurrent Mark and Sweep GC)。

  • 串行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–使用并发收集器时,开启对年老代的压缩。

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

有如下原因可能导致Full GC:

  • 年老代(Tenured)被写满
      当准备要触发一次young GC时,如果发现统计数据说之前young GC的平均晋升大小比目前old gen剩余的空间大,则不会触发young GC而是转为触发full GC(因为HotSpot VM的GC里,除了CMS的concurrent collection之外,其它能收集old gen的GC都会同时收集整个GC堆,包括young gen,所以不需要事先触发一次单独的young GC)。
  • 持久代(Perm)被写满
  • System.gc()被显示调用
  • 上一次GC之后Heap的各域分配策略动态变化
分代回收流程

分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。

新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:

  • 把 Eden + From Survivor 存活的对象放入 To Survivor 区;
  • 清空 Eden 和 From Survivor 分区;
  • From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。

每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。

老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。

4. JAVA 堆

既然GC主要发生在堆内存中,这部分我们会对堆内存进行比较详细的描述。

4.1 堆的简介

  • 堆的定义:堆是应用程序在运行期请求操作系统分配给自己的向高地址扩展的数据结构,是不连续的内存区域。
  • 堆的组成:堆内存是由存活和死亡的对象组成的
    1. 存活的对象是应用可以访问的,不会被垃圾回收。
    2. 死亡的对象是应用不可访问的、尚且还没有被垃圾收集器回收掉的对象。一直到垃圾收集器把这些对象回收掉之前,他们会一直占据堆内存空间。
  • 堆的作用程序运行时动态申请某个大小的内存空间。

4.2 堆的划分

在这里插入图片描述
新生代:刚刚新建的对象被放在Eden中。新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1。关于Survivor,参考为什么新生代内存需要有两个Survivor区以及下文的分代垃圾回收器。
老生代:如果某个对象经历了几次垃圾回收之后还存活,就会被存放到老年代中。老年代的空间一般比新生代大。

4.3 对象申请堆内存流程

那么,当我们创建一个对象后,它会被放在堆内存的哪个部分呢? 如图:
在这里插入图片描述
如果Major GC之后还是老年代不足,JVM会抛出内存不足的异常。

GC参数

在前面我们介绍了很多种类的垃圾回收器,并进行了分类。这里按照类别的划分介绍一下GC相关的参数,以便后面对于GC优化的学习。

与串行回收器相关的参数

参数名
说明
-XX:+UseSerialGC在新生代和老年代使用串行回收器。
-XX:+SuivivorRatio设置 eden 区大小和 survivor 区大小的比例。
-XX:+PretenureSizeThreshold设置大对象直接进入老年代的阈值。
当对象的大小超过这个值时,将直接在老年代分配。
-XX:MaxTenuringThreshold设置对象进入老年代的年龄的最大值。
每一次 Minor GC 后,对象年龄就加 1。
任何大于这个年龄的对象,一定会进入老年代。

与并行 GC 相关的参数

参数名
说明
-XX:+UseParNewGC在新生代使用并行收集器
-XX:+UseParallelOldGC老年代使用并行回收收集器
-XX:ParallelGCThreads设置用于垃圾回收的线程数。
通常情况下可以和 CPU 数量相等。
但在 CPU 数量比较多的情况下,设置相对较小的数值也是合理的。
-XX:MaxGCPauseMills设置最大垃圾收集停顿时间。
它的值是一个大于 0 的整数。
收集器在工作时,会调整 Java 堆大小或者其他一些参数,尽可能地把停顿时间控制在 MaxGCPauseMills 以内。
-XX:GCTimeRatio设置吞吐量大小,它的值是一个 0-100 之间的整数。
假设 GCTimeRatio 的值为 n,那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集。
-XX:+UseAdaptiveSizePolicy打开自适应 GC 策略。
在这种模式下,新生代的大小,eden 和 survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。

与 CMS 回收器相关的参数

参数名
说明
-XX:+UseConcMarkSweepGC新生代使用并行收集器,老年代使用 CMS+串行收集器。
-XX:+ParallelCMSThreads设定 CMS 的线程数量。
-XX:+CMSInitiatingOccupancyFraction设置 CMS 收集器在老年代空间被使用多少后触发,默认为 68%。
-XX:+UseFullGCsBeforeCompaction设定进行多少次 CMS 垃圾回收后,进行一次内存压缩。
-XX:+CMSClassUnloadingEnabled允许对类元数据进行回收。
-XX:+CMSParallelRemarkEndable启用并行重标记。
-XX:CMSInitatingPermOccupancyFraction当永久区占用率达到这一百分比后,启动 CMS 回收
(前提是-XX:+CMSClassUnloadingEnabled 激活了)。
-XX:UseCMSInitatingOccupancyOnly表示只在到达阈值的时候,才进行 CMS 回收。
-XX:+CMSIncrementalMode使用增量模式,比较适合单 CPU。

与 G1 回收器相关的参数

参数名
说明
-XX:+UseG1GC使用 G1 回收器。                  
-XX:+UnlockExperimentalVMOptions允许使用实验性参数。
-XX:+MaxGCPauseMills设置最大垃圾收集停顿时间。
-XX:+GCPauseIntervalMills设置停顿间隔时间。

其他常用参数

参数名
说明
-XX:+DisableExplicitGC禁用显示 GC。
-Xms初始堆大小。 如:-Xms256m
-Xmx最大堆大小。 如:-Xmx512m
-Xmn新生代大小。通常为Xmx的1/3或1/4。
新生代 = Eden + 2个Survivor空间。实际可用空间 = Eden + 1个Survivor,即90%
-XssJDK1.5+每个线程堆栈大小为1M,一般来说如果栈不是很深的话,1M是绝对够了的。
-XX:NewRatio新生代与老年代的比例。
如-XX:NewRatio = 2 ,则新生代占整个空间的1/3,老年代占2/3。
-XX:SurvivorRatio新生代中Eden与Survivor的比值,默认为8。
即Eden占新生代空间的8/10,另外两个Survivor各占1/10。
-XX:PermSize永久代(方法区)的初始大小
-XX:MaxPermSize永久代(方法区)的最大值
-XX:+PrintGCDetails打印GC信息
-XX:+HeapDumpOnOutOfMemoryError让虚拟机发生内存溢出时Dump出当前的内存堆转储快照,以便分析用

调优实例

参考JVM垃圾回收器工作原理及使用实例介绍收集器对系统性能的影响部分

JVM相关的JAVA性能优化

转自JVM GC 机制与性能优化 - 橙子wj的博客 - CSDN博客

大多说针对内存的调优,都是针对于特定情况的。但是实际中,调优很难与JAVA运行动态特性的实际情况和工作负载保持一致。也就是说,几乎不可能通过单纯的调优来达到消除GC的目的。

真正影响JAVA程序性能的,就是碎片化。碎片是JAVA堆内存中的空闲空间,可能是TLAB剩余空间,也可能是被释放掉的具有较长生命周期的小对象占用的空间。

下面是一些在实际写程序的过程中应该注意的点,养成这些习惯可以在一定程度上减少内存的无谓消耗,进一步就可以减少因为内存不足导致GC不断。类似的这种经验可以多积累交流:

  1. 减少new对象。每次new对象之后,都要开辟新的内存空间。这些对象不被引用之后,还要回收掉。因此,如果最大限度地合理重用对象,或者使用基本数据类型替代对象,都有助于节省内存;
  2. 多使用局部变量,减少使用静态变量。局部变量被创建在栈中,存取速度快。静态变量则是在堆内存;
  3. 避免使用finalize,该方法会给GC增添很大的负担;
  4. 如果是单线程,尽量使用非多线程安全的,因为线程安全来自于同步机制,同步机制会降低性能。例如,单线程程序,能使用HashMap,就不要用HashTable。同理,尽量减少使用synchronized
  5. 用移位符号替代乘除号。eg:a*8应该写作a<<3
  6. 对于经常反复使用的对象使用缓存;
  7. 尽量使用基本类型而不是包装类型,尽量使用一维数组而不是二维数组;
  8. 尽量使用final修饰符,final表示不可修改,访问效率高
  9. 单线程情况下(或者是针对于局部变量),字符串尽量使用StringBuilder,比StringBuffer要快;
  10. String为什么慢?因为String 是不可变的对象, 因此在每次对 String 类型进行改变的时候其实都等同于生成了一个新的 String 对象,然后将指针指向新的 String 对象。如果不能保证线程安全,尽量使用StringBuffer来连接字符串。这里需要注意的是,StringBuffer的默认缓存容量是16个字符,如果超过16,apend方法调用私有的expandCapacity()方法,来保证足够的缓存容量。因此,如果可以预设StringBuffer的容量,避免append再去扩展容量。如果可以保证线程安全,就是用StringBuilder。示例下面两个示例·:

致谢

  • 13
    点赞
  • 37
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值