TOC
KINA

KINA-0

Start having fun with KINA right now!

JVM基础:基本组成、垃圾回收、调优

本文深入探讨了Java虚拟机(JVM)的基本组成及其工作机制,主要涵盖类加载器、运行时数据区和执行引擎等核心组件。文中详细解释了JVM内存研究的重要部分,包括堆、栈、方法区及其与垃圾回收的关系。同时,文章还讨论了Java的垃圾回收机制,包括强、软、弱和虚引用,强调了可达性分析算法及其相较于引用计数法的优劣。此外,回收算法如标记清除、复制和分代收集的特征和策略也得到了充分说明,为理解JVM内存管理提供了全面的视角。最后,文章为如何进行JVM调优提出了若干可行的方案。

1 JVM基本组成

1.1 主要组成部分与运行流程

主要组成部分

JVM的主要组成部分:

  • ClassLoader:类加载器。
  • Runtime Data Area:运行时数据区(内存分区)
  • Execution Engine:执行引擎
  • Native Method Library:本地库接口

运行流程:

  1. 类加载器(ClassLoader)将Java代码转换为字节码
  2. 运行时数据区(Runtime Data Area)加载字节码至内存中。字节码文件只是JVM的一套指令集规范,并不能直接交给底层系统去执行,而是由执行引擎运行
  3. 执行引擎(Execution Engine)将字节码翻译为底层系统指令,再交由CPU执行去执行,此时需要调用其他语言的本地库接口(Native Method Library)来实现整个程序的功能

1.2 运行时数据区

组成部分:堆、方法区、栈、本地方法栈、程序计数器

  1. 用于解决对象实例存储的问题,为垃圾回收器管理的主要区域。
  2. 方法区在Java 7中可认为是堆的一部分,用于存储已被虚拟机加载的信息,常量、静态变量、即时编译器编译后的代码。
  3. 用于解决程序运行的问题,存储栈帧,栈帧中存储局部变量表、操作数栈、动态链接、方法出口等信息。
  4. 本地方法栈与栈功能相同,执行的是本地方法,为Java调用非Java代码的接口。
  5. 程序计数器(PC寄存器)存放当前线程所执行的字节码的行数。JVM工作时通过改变这个计数器的值来选取下一个需要执行的字节码指令。

1.2.1 程序计数器

线程私有的、内部保存的字节码的行号,用于记录正在执行的字节码指令的地址。

javap -verbose xx.class:打印堆栈大小,局部变量的数量和方法的参数。

程序计数器

java虚拟机对于多线程采用线程轮流切换并分配线程执行时间的方法。在任一时间点上,一个处理器只会处理一个线程,如果当前被执行线程的所分配执行时间用尽(被挂起),处理器会切换执行另一个线程。当该线程的执行时间用尽时,处理器会切换至刚才被挂起的线程,根据程序计数器获取上一次执行的行号,继续向下执行。

程序计数器是JVM规范中唯一一个没有规定出现OOM的区域,所以该空间不会进行GC。

1.2.2 堆

线程共享的区域,主要用于保存对象实例、数组等。当堆中没有内存空间可分配给实例且无法再扩展时,会抛出OutOfMemoryError。

堆

堆中存在年轻代老年代方法区/永久区(Java 7)或永久代(Java 8)。

  • Young区(年轻代)被划分为三部分——Eden区和两个大小严格相同的Survivor区,其中在Survivor区间中,某一时刻只有其中一个被使用,另一个留做垃圾收集时复制对象用。Eden区满时, GC会将存活的对象移到空闲的Survivor区间中,根据JVM的策略,在经过几次垃圾收集后,仍存活于Survivor的对象将被移动到Tenured区间。
  • Tenured区(老年代)主要保存生命周期长的对象(老的对象)。当对象在Young复制转移一定次数后就会被转移到Tenured区。
  • Perm代(永久代,≈方法区)主要保存的是保存的类信息、静态变量、常量、编译后的代码。在Java 7中,方法区在堆上,会受到GC的管理,且存在大小限制,大量动态生成类放入方法区很容易造成OOM。
    为了避免方法区出现OOM,Java 8中将堆上的方法区/永久代移动到本地内存上重新开辟了一块空间,称为元空间。元空间并不在虚拟机中,因此默认情况下元空间的大小仅受本地内存限制。

1.2.3 方法区

方法区类似于传统语言的编译代码存储区,其存储每个类的结构,如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化及接口初始化中使用的特殊方法。

方法区在虚拟机启动时创建。尽管方法区在逻辑上是堆的一部分,但简单的实现常选择不进行垃圾收集或压缩它。本规范不要求方法区的位置或用于管理已编译代码的策略。可以是固定大小,也可以根据计算需要扩大,若不需要更大的方法区可以缩小。方法区的内存不需要是连续的。

Java 虚拟机为程序员或用户提供对方法区初始大小的控制,以及在方法区域大小可变的情况下,对最大和最小方法区域大小的控制。

以下异常情况与方法区相关:

  • 如果方法区域中的内存无法满足分配请求,Java 虚拟机将抛出OutOfMemoryError。

1.2.4 虚拟机栈

栈描述方法执行时的内存模型,是线程私有的,生命周期与线程相同。每个方法被执行的同时会创建栈桢,保存执行方法时的局部变量、动态连接信息、方法返回地址信息等等。方法开始执行的时候会进栈,方法执行完会出栈(相当于清空数据),所以栈不需要进行GC

堆、栈的区别:

  1. 栈内存一般用来存储局部变量和方法调用;堆内存用来存储Java对象和数组。堆会GC垃圾回收;栈不会。
  2. 栈内存是线程私有的,堆内存是线程共有的。
  3. 两者异常错误不同,若栈内存或者堆内存不足都会抛出异常。
    • 栈空间不足:java.lang.StackOverFlowError
    • 堆空间不足:java.lang.OutOfMemoryError

1.3 直接内存

不受JVM内存回收管理,是虚拟机的系统内存。常见于NIO操作时用于数据缓冲区,分配回收成本较高,但读写性能高。

【例】在本地电脑中的一个较大的文件(超过100m)从一个磁盘挪到另外一个磁盘。代码如下

/**
 * 演示 ByteBuffer 作用
 */
public class Demo1_9 {
    static final String FROM = "E:\\download\\rbsp-2\\hyperplasma.mp4";
    static final String TO = "E:\\new.mp4";
    static final int _1Mb = 1024 * 1024;

    public static void main(String[] args) {
        io(); // io用时:1535.586957 1766.963399 1359.240226
        directBuffer(); // directBuffer用时:479.295165 702.291454 562.56592
    }

    private static void directBuffer() {
        long start = System.nanoTime();
        try (FileChannel from = new FileInputStream(FROM).getChannel();
             FileChannel to = new FileOutputStream(TO).getChannel();
        ) {
            ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb);
            while (true) {
                int len = from.read(bb);
                if (len == -1) {
                    break;
                }
                bb.flip();
                to.write(bb);
                bb.clear();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.nanoTime();
        System.out.println("directBuffer用时:" + (end - start) / 1000_000.0);
    }

    private static void io() {
        long start = System.nanoTime();
        try (FileInputStream from = new FileInputStream(FROM);
             FileOutputStream to = new FileOutputStream(TO);
        ) {
            byte[] buf = new byte[_1Mb];
            while (true) {
                int len = from.read(buf);
                if (len == -1) {
                    break;
                }
                to.write(buf, 0, len);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.nanoTime();
        System.out.println("io用时:" + (end - start) / 1000_000.0);
    }
}

可见,传统IO时间比NIO操作时间长得多,即NIO的读性能更好。这与JVM的直接内存有关。下图为传统阻塞IO的数据传输流程。

传统阻塞IO

下图为NIO传输数据的流程。其中主要使用了直接内存,不需要在堆中开辟空间进行数据的拷贝,JVM可以直接操作直接内存,从而使数据读写传输更快。

NIO

1.5 类加载器

类加载器

  • 类加载器:用于装载字节码文件(.class文件)
  • 运行时数据区:用于分配存储空间
  • 执行引擎:执行字节码文件或本地方法
  • 垃圾回收器:用于对JVM中的垃圾内容进行回收

JVM只会运行二进制文件,而类加载器(ClassLoader)的主要作用就是将字节码文件加载到JVM中,从而让Java程序能够启动。现有的类加载器基本都是java.lang.ClassLoader的子类,该类主要用于将指定的类找到或生成对应的字节码文件,同时类加载器还会负责加载程序所需要的资源。

1.5.1 类加载器种类

根据各自加载范围的不同可划分为四种类加载器:

  • 启动类加载器(BootStrap ClassLoader):该类并不继承ClassLoader类,由C++编写实现。用于加载JAVA_HOME/jre/lib目录下的类库。
  • 扩展类加载器(ExtClassLoader):该类是ClassLoader的子类,主要加载JAVA_HOME/jre/lib/ext目录中的类库。
  • 应用类加载器(AppClassLoader):该类是ClassLoader的子类,主要用于加载classPath下的类,即加载开发者自己编写的Java类。
  • 自定义类加载器:开发者自定义类继承ClassLoader,实现自定义类加载规则。

各种类加载器的层次结构如下所示:

类加载器的层次结构

类加载器的体系并不是“继承”体系,而是委派体系——类加载器首先会到自己的parent中查找类或者资源,如果找不到才会到自己本地查找。类加载器的委托行为动机是为了避免相同的类被加载多次。

1.5.2 类装载的执行过程

类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)。

类装载的执行过程

  1. 加载:查找和导入class文件
    • 获取类的二进制字节流 ,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
    • 在Java堆中生成一个Class对象,作为方法区中这些数据的访问入口
  2. 验证:保证加载类的准确性
    • 文件格式验证:是否符合Class文件的规范
    • 元数据验证
      • 这个类是否有父类(除了Object这个类之外,其余的类都应该有父类)
      • 这个类是否继承(extends)了被final修饰过的类(被final修饰过的类表示类不能被继承)
      • 类中的字段、方法是否与父类产生矛盾。(被final修饰过的方法或字段是不能覆盖的)
    • 字节码验证:主要目的是通过对数据流和控制流的分析,确定程序语义是合法的、符合逻辑的。
    • 符号引用验证:以一组符号来描述所引用的目标,符号可以是任何形式的字面量

      【例】int i = 3;
      字面量:3
      符号引用:i

  3. 准备:为类变量分配内存并设置类变量初始值
    • Java 中的变量有“类变量”和“类成员变量”两种类型
      • 类变量:被静态(static) 修饰的变量
      • 类成员变量:非静态修饰的变量
    • 在准备阶段,JVM 只会为“类变量”分配内存,而不会为“类成员变量”分配内存;“类成员变量”的内存分配需要等到类加载的初始化阶段才开始。
  4. 解析:把类中的符号引用转换为直接引用
    • 例如方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法。
  5. 初始化:对于类的静态变量,静态代码块执行初始化操作
    • 只对静态(static)修饰的变量或语句块进行初始化。如果初始化一个类时,其父类尚未初始化,则优先初始化其父类。如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。
  6. 使用:JVM 开始从入口方法开始执行用户的程序代码
  7. 卸载:当用户程序代码执行完毕后,JVM便开始销毁创建的Class对象,最后负责运行的JVM也退出内存

1.5.3 双亲委派模型

如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就返回成功;只有父类加载器无法完成此加载任务时,才由下一级去加载。

双亲委派模型

  • JVM为什么采用双亲委派机制?
    1. 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
    2. 为了安全,保证类库API不会被修改
  • 【例】在工程中新建java.lang包,在该包下新建String类,并定义main函数
    public class String {
    public static void main(String[] args) {
       System.out.println("demo info");
    }
    }

    此时执行main函数,会出现异常,在类java.lang.String中找不到main方法。出现该信息是因为由双亲委派的机制,java.lang.String的在启动类加载器(Bootstrap classLoader)得到加载,因为在核心jre库中有其相同名字的类文件,但该类中并没有main方法。这样就能防止恶意篡改核心API库。


2 垃圾回收

2.1 垃圾回收机制

为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,Java语言中有自动的垃圾回收机制(Garbage Collection,GC)。有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。

在进行垃圾回收时,不同的对象引用类型,GC会采用不同的回收时机。自动垃圾回收的算法非常重要,如果因为算法的不合理导致内存资源一直没有释放,同样也可能导致内存溢出。

除了Java语言,C#、Python等语言也都有的自动垃圾回收机制。

2.2 引用

2.2.1 强引用

最为普通的引用方式,表示一个对象处于有用且必须的状态。如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收。

User user = new User();

2.2.2 软引用

表示一个对象处于有用且非必须状态。如果一个对象处于软引用,在内存空间足够的情况下,GC机制并不会回收它;而在内存空间不足时,则会在OOM异常出现之前对其进行回收。但值得注意的是,因为GC线程优先级较低,软引用并不会立即被回收。

User user = new User();
SoftReference softReference = new SoftReference(user);

2.2.3 弱引用

表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收。同样地,因为GC线程优先级较低,所以弱引用也并不是会被立刻回收。

User user = new User();
WeakReference weakReference = new WeakReference(user);

【例】ThreadLocal内存泄漏问题:ThreadLocal用的就是弱引用,源码如下

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
         super(k);
         value = v; //强引用,不会被回收
     }
}

Entry的key是当前ThreadLocal,value值是我们要设置的数据。

WeakReference表示的是弱引用,当JVM进行GC时,一旦发现了只具有弱引用的对象,不管当前内存空间是否足够,都会回收它的内存。但是value是强引用,它不会被回收掉。

ThreadLocal使用建议:使用完毕后注意调用清理方法。

2.2.4 虚引用

/* 例 */
PhantomReference a = new PhantomReference(new A(), referenceQueue);

必须配合引用队列一起使用。当虚引用所引用的对象被回收时,由Reference Handler线程将虚引用对象入队,这样即可知道哪些对象被回收,从而对它们关联的资源做进一步处理。

package com.itheima.basic;

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
import java.util.ArrayList;
import java.util.List;

public class TestPhantomReference {
    public static void main(String[] args) throws InterruptedException {
        ReferenceQueue<String> queue = new ReferenceQueue<>();
        List<MyResourse> list = new ArrayList<>();
        list.add(new MyResourse(new String("a"),queue));
        list.add(new MyResourse("b",queue));
        list.add(new MyResourse(new String("c"),queue));

        System.gc();

        Thread.sleep(100);

        Object ref ;
        while ((ref =queue.poll()) != null){
            if(ref instanceof MyResourse){
                MyResourse.clean();
            }
        }
    }

    static class MyResourse extends PhantomReference<String> {
        public MyResourse(String referent, ReferenceQueue<? super  String> q){
            super(referent,q);
        }

        //释放外部资源的方法
        public static void clean(){
            System.out.println("clean");
        }
    }
}

2.3 垃圾定位方法

垃圾的定义:如果一个或多个对象没有任何的引用指向它了,那么该对象就是垃圾。如果定位了垃圾,则有可能会被垃圾回收器回收。

2.3.1 引用计数法

一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收。

优点:

  • 实时性较高,无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收。
  • 在垃圾回收过程中,应用无需挂起。如果申请内存时,内存不足,则立刻报OOM错误。
  • 区域性:更新对象的计数器时,只是影响到该对象,不会扫描全部对象。

缺点:

  • 每次对象被引用时,都需要去更新计数器,有一点时间开销。
  • 浪费CPU资源,即使内存够用,仍然在运行时进行计数器的统计。
  • 无法解决循环引用问题,会引发内存泄露。(最大的缺点)

【例1】引用计数法示例

String demo = new String("123");

引用计数法1

String demo = null;

引用计数法

【例2】当对象间出现了循环引用时,引用计数法就会失效

public static void main(String[] args) {
    Demo a = new Demo("a");
    Demo b = new Demo("b");

    a.instance = b;
    b.instance = a;

    a = null;
    b = null;
}

虽然a和b都为null,但是由于a和b存在循环引用,这样a和b永远都不会被回收。

2.3.2 可达性分析算法

现在的虚拟机大都采用可达性分析算法来确定哪些内容是垃圾。

存在一个根节点GC Roots,引出它下面指向的下一个节点,再以下一个节点开始找出它下面的节点。依次往下类推,直至所有的节点全部遍历完毕。

  • 根对象应为那些必定不做垃圾回收的对象,如局部变量,静态方法,静态变量,类信息等。
  • 核心为判断某对象是否与根对象有直接或间接的引用,如果没有被引用,则可以当做垃圾回收

可达性分析算法

X、Y这两个节点是可回收的,但并不会马上的被回收,因为对象中存在一个方法finalize()。当对象被标记为可回收后,当发生GC时,首先会判断这个对象是否执行了finalize方法,若还未执行finalize,则会先执行该方法;在该方法执行中,可以设置当前这个对象与GC ROOTS产生关联,则该方法执行完成后,GC会再次判断对象是否可达,若仍不可达,则会进行回收,若可达了,则不会进行回收。

finalize()方法对每个对象只会执行一次。如果第一次执行这个方法时设置了当前对象与RC ROOTS关联,那么这一次不会进行回收;当该对象第二次被标记为可回收时,该对象的finalize方法不会再次执行。

GC ROOTS示例:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象:demo是栈帧中的本地变量,当demo = null时,由于此时demo充当了GC Root的作用,demo与原来指向的实例new Demo()断开了连接,对象被回收。
public class Demo {
    public static void main(String[] args) {
        Demo demo = new Demo();
        demo = null;
    }
}
  • 方法区中类静态属性引用的对象:当栈帧中的本地变量b = null时,由于b原指向的对象与GC Root(变量b)断开了连接,所以b原指向的对象会被回收。而由于给a赋值了变量的引用,a在此时是类静态属性引用,充当了GC Root的作用,其指向的对象依然存活
public class Demo {
    public static Demo a;

    public static void main(String[] args) {
        Demo b = new Demo();
        b.a = new Demo();
        b = null;
    }
}
  • 方法区中常量引用的对象:常量a指向的对象并不会因为demo指向的对象被回收而回收
public class Demo {
    public static final Demo a = new Demo();

    public static void main(String[] args) {
        Demo demo = new Demo();
        demo = null;
    }
}
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

    2.4 垃圾回收算法

2.4.1 标记清除算法

将垃圾回收分为2个阶段:标记清除

  1. 根据可达性分析算法得出的垃圾进行标记
  2. 对这些标记为可回收的内容进行垃圾回收

标记清除算法

可见,标记清除算法解决了引用计数算法中的循环引用的问题,没有从root节点引用的对象都会被回收。

缺点:

  • 效率较低。标记和清除两个动作都需要遍历所有的对象,并且在GC时需要停止应用程序,对于交互性要求比较高的应用而言体验极差。
  • 重要】通过标记清除算法清理出来的内存碎片化较为严重,因为被回收的对象可能存在于内存的各个角落,所以清理出来的内存是不连贯的。

2.4.2 复制算法

核心为将原有的内存空间一分为二,每次只用其中的一块。在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收。

如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之,则不适合。

复制算法

  1. 将内存区域分成两部分,每次操作其中一个。
  2. 当进行垃圾回收时,将正在使用的内存区域中的存活对象移动到未使用的内存区域。当移动完对这部分内存区域一次性清除。
  3. 周而复始。

优点:

  • 在垃圾对象多的情况下,效率较高
  • 清理后,内存无碎片

缺点:

  • 分配的2块内存空间,在同一个时刻只能使用一半,内存使用率较低

2.4.3 标记整理算法

标记压缩算法在标记清除算法的基础之上做了优化改进。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的直接清理可回收对象,而是将存活对象都向内存另一端移动,然后清理边界以外的垃圾,从而解决了碎片化的问题。

标记整理算法

  1. 标记垃圾。
  2. 需要清除的向右走,不需要清除的向左走。
  3. 清除边界以外的垃圾。

优缺点同标记清除算法,解决了标记清除算法的碎片化的问题。同时,标记压缩算法多了一步对象移动内存位置的步骤,对效率有一定的影响。

与复制算法对比:复制算法标记完就复制;标记整理算法需等到把所有存活对象都标记完毕再进行整理。

2.4.4 分代收集算法

java8中,堆被分为了两份:新生代老年代(1 : 2)。java7时,还存在一个永久代

新生代内部又被分为三个区域:Eden区S0区S1区(8 : 1 : 1)

  • 对新生代产生GC:Minor GCYoung GC
  • 对老年代产生GC:Major GC
  • 对新生代和老年代产生GC:Full GC —— 新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免

分代收集算法

工作机制:

  1. 当创建一个对象时,该对象会被分配在新生代的Eden区。当Eden区即将满时,触发Young GC。
  2. 进行Young GC之后,此时在Eden区存活的对象被移动到S0区,并且当前对象的年龄会加1,清空Eden区。
  3. 当再一次触发Young GC时,会把Eden区中存活下来的对象和S0中的对象移动到S1区中,这些对象的年龄会加1,清空Eden区和S0区。
  4. 当再一次触发Young GC时,会把Eden区中存活下来的对象和S1中的对象,移动到S0区中,这些对象的年龄会加1,清空Eden区和S1区。

对象何时晋升到老年代:

  1. 对象的年龄达到了某一个限定的值(默认15岁 ),则该对象会进入到老年代中。
  2. 大对象。
  3. 如果在Survivor区中相同年龄的对象的所有大小之和超过Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

当老年代满了之后会触发FullGC,同时回收新生代和老年代,当前只会存在一个FullGC的线程进行执行,其他的线程全部会被挂起。

2.5 新生代、老年代、永久代

  • 新生代主要用来存放新生的对象。一般占据堆空间的1/3。
    • 在新生代中,保存着大量的刚刚创建的对象,但是大部分的对象都是朝生夕死,所以在新生代中会频繁的进行MinorGC,进行垃圾回收。
    • 新生代又细分为三个区:Eden区、SurvivorFrom区(S0)、ServivorTo区(S1)。三个区的默认比例为:8:1:1
  • 老年代主要存放应用中生命周期长的内存对象。
    • 老年代比较稳定,不会频繁的进行MajorGC。在MajorGC之前会先进行一次MinorGC,使得新生的对象进入老年代而导致空间不够才使得触发。
    • 当无法找到足够大的连续空间分配给新创建的较大对象时,也会提前触发一次MajorGC进行垃圾回收腾出空间。
  • 永久代指永久保存区域。主要存放Class和Meta(元数据)的信息。Class在被加载的时候被放入永久区域,它和存放的实例的区域不同。
    • 在Java8中,永久代已经被移除,取而代之的是一个称之为“元数据区”(元空间)的区域。元空间和永久代类似,都是对JVM中规范中方法的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存的限制。

2.6 垃圾回收器

jvm中实现了多种垃圾收集器,包括:串行垃圾收集器、并行垃圾收集器、CMS(并发)垃圾收集器、G1垃圾收集器

2.6.1 Serial收集器

串行垃圾收集器,作用于新生代。使用单线程进行垃圾回收,采用复制算法。垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停,等待垃圾回收的完成。这种现象称之为STW(Stop-The-World)。

对于交互性较强的应用而言,这种垃圾收集器是不可接受的。因此一般在Javaweb应用中是不会采用该收集器的。

Serial收集器

2.6.2 Parallel New收集器

并行垃圾收集器,在串行垃圾收集器的基础之上做了改进,采用复制算法,同样应用在年轻代。将单线程改为了多线程进行垃圾回收,这样可以缩短垃圾回收的时间(此处指并行能力较强的机器)。但对于其他的行为(收集算法、stop the world、对象分配规则、回收策略等)同Serial收集器一样。JDK8默认使用此垃圾回收器

同串行垃圾回收器一样,并行垃圾收集器在收集的过程中也会暂停应用程序,只是并行执行速度更快些,暂停的时间更短一些。

Parallel New收集器

2.6.3 Parallel Scavenge收集器

一种应用于新生代并行垃圾回收器,采用复制算法。其目标是达到一个可控的吞吐量吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))即虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,吞吐量就是99%。这样可以高效率的利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。

  • 停顿时间越短对于需要与用户交互的程序来说越好,良好的响应速度能提升用户的体验。
  • 高吞吐量可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适合在后台运算而不太需要太多交互的任务。

2.6.4 Serial Old收集器

运行于老年代的单线程Serial收集器,采用标记-整理算法,主要给Client模式下的虚拟机使用。

2.6.5 Parallel Old收集器

应用于老年代的并行垃圾回收器,采用标记-整理算法。在注重吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge + Parallel Old收集器。

2.6.6 CMS垃圾收集器

Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器。该回收器针对老年代进行垃圾回收。以获取最短回收停顿时间为目标,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行

CMS垃圾回收器的执行过程如下:

CMS垃圾收集器

  1. 初始标记(Initial Mark):仅标记GC Roots能直接关联到的对象,速度快,但是需要Stop The World
  2. 并发标记(Concurrent Mark):就是进行追踪引用链的过程,可以和用户线程并发执行。
  3. 重新标记(Remark):修正并发标记阶段因用户线程继续运行而导致标记发生变化的那部分对象的标记记录,比初始标记时间长但远比并发标记时间短,需要Stop The World
  4. 并发清除(Concurrent Sweep):清除标记为可以回收对象,可以和用户线程并发执行

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

2.6.7 G1垃圾收集器

对于垃圾回收器来说,前面的三种要么一次性回收年轻代,要么一次性回收老年代。而且现代服务器的堆空间已经可以很大了。为了更加优化GC操作,所以出现了G1。

G1是一款同时应用于新生代和老年代、采用标记-整理算法、软实时、低延迟、可设定目标(最大STW停顿时间)的垃圾回收器,用于代替CMS,适用于较大的堆(>4~6G),在JDK9之后默认使用G1

G1垃圾收集器相对比其他收集器而言,最大的区别在于取消了年轻代、老年代的物理划分。取而代之的是将堆划分为若干个区域(Region),这些区域中包含了有逻辑上的年轻代、老年代区域。这样做的好处是无需使用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。

G1垃圾收集器

在G1划分的区域中,年轻代的垃圾收集依然采用暂停所有应用线程(STW)的方式,将存活对象拷贝到老年代或者Survivor空间,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会出现CMS内存碎片问题。

其提供了三种模式垃圾回收模式: young GC、Mixed GC、Full GC。在不同的条件下被触发。

  • Young GC:发生在年轻代的GC算法。
    • 一般对象(除了巨型对象)都在Eden Region中分配内存,当所有Eden Region被耗尽无法申请内存时,就会触发一次Young GC,这种触发机制和之前的Young GC差不多,执行完一次Young GC。
    • 活跃对象会被拷贝到Survivor Region或者晋升到Old Region中,空闲的region会被放入空闲列表中,等待下次被使用。
  • Mixed GC:当越来越多的对象晋升到老年代(Old Region)时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器。
    • 该算法不同于Old GC,除了回收整个Young Region,还会回收一部分的Old Region(并非全部),由此可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。
    • 在CMS中,当老年代的使用率达到80%就会触发一次cms gc。在G1中,mixed gc也可以通过-XX:InitiatingHeapOccupancyPercent设置阈值,默认为45%。当老年代大小占整个堆大小百分比达到该阈值,则触发Mixed GC。
    • 执行过程类似于CMS:
      1. 初始标记过程(Initial Marking):整个过程STW,标记从GC Root可达的对象。
      2. 并发标记过程(Concurrent Marking):整个过程GC Collector线程与应用线程可以并行执行,标记出GC Root可达对象衍生出去的存活对象,并收集各个Region的存活对象信息。
      3. 最终标记过程(Remark):整个过程STW,标记出那些在并发标记过程中遗漏的,或者内部引用发生变化的对象。
      4. 垃圾清除过程(Clean Up):,如果发现一个Region中没有存活对象,则把该Region加入到空闲列表中。
  • Full GC:如果对象内存分配速度过快,Mixed GC来不及回收,导致老年代被填满,就会触发一次Full GC。
    • G1的Full GC算法就是单线程执行的Serial Old GC,会导致异常长时间的暂停时间,需要进行不断的调优,因此尽可能避免Full GC。

3 调优

3.1 调优方法

3.1.1 Tomcat设置vm参数

修改TOMCAT_HOME/bin/catalina.sh文件,如下图

JAVA_OPTS="-Xms512m -Xmx1024m"

Tomcat设置vm参数

3.1.2 SpringBoot项目jar文件启动

通常在linux系统下直接加参数启动springboot项目

nohup java -Xms512m -Xmx1024m -jar xxxx.jar --spring.profiles.active=prod &
  • nohup : 用于在系统后台不挂断地运行命令,退出终端不会影响程序的运行
  • 参数 & :让命令在后台执行,终端退出后命令仍旧执行。

3.2 调优参数

对于JVM调优,主要是调整年轻代、年老代、元空间的内存空间大小及使用的垃圾回收器类型。

https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html

  1. 设置堆的初始大小和最大大小。为了防止垃圾收集器在初始大小、最大大小之间收缩堆而产生额外的时间,通常把最大、初始大小设置为相同的值。
-Xms    # 设置堆的初始化大小

-Xmx    # 设置堆的最大大小
  1. 设置年轻代中Eden区和两个Survivor区的大小比例。该值如果不设置,则默认比例为8:1:1。Java官方通过增大Eden区的大小,来减少YGC发生的次数,但有时虽然次数减少了,但Eden区满时,由于占用的空间较大,导致释放缓慢,此时STW的时间较长,因此需要按照程序情况去调优。
-XXSurvivorRatio=3  # 表示年轻代中的分配比率:survivor:eden = 2:3
  1. 年轻代和老年代默认比例为1:2。可以通过调整二者空间大小比率来设置两者的大小。
-XX:newSize     # 设置年轻代的初始大小
-XX:MaxNewSize  # 设置年轻代的最大大小,初始大小和最大大小两个值通常相同
  1. 线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,但一般256K就够用。通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。
-Xss    # 对每个线程stack大小的调整,-Xss 128k
  1. 一般来说,当survivor区不够大或者占用量达到50%,就会把一些对象放到老年区。通过设置合理的eden区,survivor区及使用率,可以将年轻对象保存在年轻代,从而避免Full GC,使用-Xmn设置年轻代的大小
  2. 系统CPU持续飙高的话,首先先排查代码问题,如果代码没问题,则咨询运维或者云服务器供应商,通常服务器重启或者服务器迁移即可解决。
  3. 对于占用内存比较多的大对象,一般会选择在老年代分配内存。如果在年轻代给大对象分配内存,年轻代内存不够了,就要在eden区移动大量对象到老年代,这些移动的对象可能很快消亡,因此导致Full GC。通过设置参数-XX:PetenureSizeThreshold=1000000(单位为B),标明对象大小超过1M时,在老年代分配内存空间。
  4. 一般情况下,年轻对象放在Eden区,当第一次GC后,如果对象还存活,放到Survivor区,此后,每GC一次,年龄增加1,当对象的年龄达到阈值,就被放到老年区。这个阈值可以同构-XX:MaxTenuringThreshold设置。如果想让对象留在年轻代,可以设置比较大的阈值。
-XX:+UseParallelGC      # 年轻代使用并行垃圾回收收集器。这是一个关注吞吐量的收集器,可以尽可能的减少垃圾回收时间。

-XX:+UseParallelOldGC   # 设置老年代使用并行垃圾回收收集器。
  1. 尝试使用大的内存分页:使用大的内存分页增加CPU的内存寻址能力,从而系统的性能。
-XX:+LargePageSizeInBytes   # 设置内存页的大小
  1. 使用非占用的垃圾收集器。
-XX:+UseConcMarkSweepGC     # 老年代使用CMS收集器降低停顿。

3.3 常用的命令工具

3.3.1 jps(Java Process Status)

输出JVM中运行的进程状态信息(现在一般使用jconsole)

jps

3.3.2 jstack

查看java进程内线程的堆栈信息。

jstack [option] <pid>

【案例】使用jstack查看进行堆栈运行信息

public class Application {
    public static void main(String[] args) throws InterruptedException {
        while (true){
            Thread.sleep(1000);
            System.out.println("666");
        }
    }
}

 jstack

3.3.3 jmap

用于生成堆转存快照

  • jmap [options] pid:内存映像信息
  • jmap -heap pid:显示Java堆的信息
  • jmap -dump format=b file=heap.hprof pidformat=b表示以hprof二进制格式转储Java堆的内存,file=<filename>用于指定快照dump文件的文件名。

【例】显示某java运行的堆信息

C:\Users\yuhon>jmap -heap 53280
Attaching to process ID 53280, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.321-b07

using thread-local object allocation.
Parallel GC with 8 thread(s)   //并行的垃圾回收器

Heap Configuration:  //堆配置
   MinHeapFreeRatio         = 0   //空闲堆空间的最小百分比
   MaxHeapFreeRatio         = 100  //空闲堆空间的最大百分比
   MaxHeapSize              = 8524922880 (8130.0MB) //堆空间允许的最大值
   NewSize                  = 178257920 (170.0MB) //新生代堆空间的默认值
   MaxNewSize               = 2841640960 (2710.0MB) //新生代堆空间允许的最大值
   OldSize                  = 356515840 (340.0MB) //老年代堆空间的默认值
   NewRatio                 = 2 //新生代与老年代的堆空间比值,表示新生代:老年代=1:2
   SurvivorRatio            = 8 //两个Survivor区和Eden区的堆空间比值为8,表示S0:S1:Eden=1:1:8
   MetaspaceSize            = 21807104 (20.796875MB) //元空间的默认值
   CompressedClassSpaceSize = 1073741824 (1024.0MB) //压缩类使用空间大小
   MaxMetaspaceSize         = 17592186044415 MB //元空间允许的最大值
   G1HeapRegionSize         = 0 (0.0MB)//在使用 G1 垃圾回收算法时,JVM 会将 Heap 空间分隔为若干个 Region,该参数用来指定每个 Region 空间的大小。

Heap Usage:
PS Young Generation
Eden Space: //Eden使用情况
   capacity = 134217728 (128.0MB)
   used     = 10737496 (10.240074157714844MB)
   free     = 123480232 (117.75992584228516MB)
   8.000057935714722% used
From Space: //Survivor-From 使用情况
   capacity = 22020096 (21.0MB)
   used     = 0 (0.0MB)
   free     = 22020096 (21.0MB)
   0.0% used
To Space: //Survivor-To 使用情况
   capacity = 22020096 (21.0MB)
   used     = 0 (0.0MB)
   free     = 22020096 (21.0MB)
   0.0% used
PS Old Generation  //老年代 使用情况
   capacity = 356515840 (340.0MB)
   used     = 0 (0.0MB)
   free     = 356515840 (340.0MB)
   0.0% used

3185 interned Strings occupying 261264 bytes.

3.3.4 jhat

用于分析jmap生成的堆转存快照(一般不推荐使用,而是使用Ecplise Memory Analyzer)

3.3.5 jstat

JVM统计监测工具。可以用来显示垃圾回收信息、类加载信息、新生代统计信息等。

常见参数:

  1. 总结垃圾回收统计
jstat -gcutil pid

jstat

字段 含义
S0 幸存1区当前使用比例
S1 幸存2区当前使用比例
E 伊甸园区使用比例
O 老年代使用比例
M 元数据区使用比例
CCS 压缩使用比例
YGC 年轻代垃圾回收次数
YGCT 年轻代垃圾回收消耗时间
FGC 老年代垃圾回收次数
FGCT 老年代垃圾回收消耗时间
GCT 垃圾回收消耗总时间
  1. 垃圾回收统计
jstat -gc pid

jstat垃圾回收统计

3.4 常用的可视化工具

3.4.1 jconsole

一款用于对jvm的内存、线程、类的监控,基于jmx的GUI性能监控工具。

打开方式:java安装目录bin目录下直接启动jconsole.exe即可

jconsole

可以监控内存、线程、类等信息

监控内存、线程、类等信息

3.4.2 VisualVM故障处理工具

能够监控线程、内存情况、查看方法的CPU时间和内存中的对象、已被GC的对象,反向查看分配的堆栈。

打开方式:java安装目录bin目录下直接启动jvisualvm.exe即可

VisualVM

监控程序运行情况

监控程序运行情况

查看运行中的dump

查看运行中的dump

查看堆中的信息

查看堆中的信息

3.5 内存泄露排查方案

  1. 通过jmap指定打印内存快照dump
    有时内存溢出之后程序则会直接中断,而jmap只能打印在运行中的程序,所以建议通过参数的方式的生成dump文件,配置如下:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/home/app/dumps/      # 指定生成后文件的保存目录
  1. 通过VisualVM(Ecplise MAT)工具去分析dump文件
    VisualVM可以加载离线的dump文件,如下图所示
    文件→装入→选择dump文件即可查看堆快照信息

分析dump文件

  1. 通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题

定位

  1. 找到对应的代码,通过阅读上下文的情况,进行修复即可

3.6 CPU飙高排查方案

  1. 使用top命令查看占用cpu的情况

top命令查看占用cpu

  1. 通过top命令查看后,可以查看是哪一个进程占用cpu较高(上图所示的进程为:30978)
  2. 查看当前线程中的进程信息
ps H -eo pid,tid,%cpu | grep 30978
  • pid:进行id
  • tid:进程中的线程id
  • %:cpu使用率

查看当前线程中的进程信息

  1. 通过上图分析,在进程30978中的线程30979占用cpu较高
    • 上述的线程id是一个十进制。需要把这个线程id转换为16进制,因为通常在日志中展示的都是16进制的线程id名称
    • 转换方式:在linux中执行命令printf "%x\n" 30979,如下图所示

转换方式:在linux中执行命令

  1. 可以根据线程id找到有问题的线程,进一步定位到问题代码的源码行号
jstack 30978    # 此处为进程id

进一步定位

发表评论