jvm

JVM 内存区域

线程共享区域

  • 堆(Heap)

    堆是JVM中最大的一块内存区域,用于存放对象实例,几乎所有的对象实例都在堆中分配。堆是线程共享的,每个线程都可以访问堆中的对象

    堆又分为新生代和老年代,新生代又分为Eden区、Survivor区(一般有两个,称为From区和To区)

  • 方法区(元空间)

    方法区是用于存放类的元信息常量池静态变量等数据。在JDK1.8及以前的版本,方法区是位于永久代的,而在JDK1.8及以后的版本,方法区被移动到了元空间

    方法区主要存储以下内容:

    • 类的Class对象:类加载器加载类后,会在方法区中为该类创建一个Class对象。
    • 方法信息:存放类中声明的每个方法的信息,包括方法名、返回值类型、参数类型等。
    • 字符串常量池:用于存放字符串常量。
    • 静态变量:存放类的静态变量。

非线程共享区域

  • 虚拟机栈

    • 栈帧 Java调用一个方法就是压入一个栈帧的过程

      • 局部变量表

        ​ 用于存放方法参数和方法内部定义的变量(局部变量必须赋予初始值)

      • 操作数栈

        ​ 虚拟机计算的临时存储区域,JVM虚拟机对数据的操作是通过指定的压栈和弹栈完成的

      • 动态链接

        ​ 指定了栈帧所属方法的引用

      • 方法出口

        ​ 指定了方法执行完毕或者异常后需要被调用的位置,程序才能继续执行

  • 本地方法栈

    ​ java跨语言调用 native方法(本地C类库方法)的相关信息

  • 程序计数器

    ​ 记录字节码执行位置。多线程环境(避免线程上下文切换忘记之前代码执行到哪里)

方法区详解

JDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。符号引用转移到直接内存(native heap)

元空间:JDK1.8开始,取消了方法区,取而代之的是位于直接内存的元空间(metaSpace)

  • 字符串常量池
  • 静态变量

符号引用:符号引用是一个字符串,它给出了被引用内容的描述,类的引用必须是全类名组成的,符号描述能精准定位被引用的内容(类,方法,字符)

作用:帮助JVM快速定位加载类

1
2
类:Java.lang.String
对应描述符:Ljava/lang/String;

方法信息

类中声明的每一个方法的信息,包括方法名、返回值类型、参数类型、修饰符、异常、方法的字节码

类的Class对象

类加载将二进制流转为方法区运行时数据会将加载类的Class对象分配到方法区

元空间

Java7之前,方法区位于永久代,永久代和堆虽然是内存隔离的。但是本质上使用的还是JVM内存,如果JVM内存设置过小,永久代就会有内存溢出风险,因此Java8之后废弃了永久代,使用了元空间,元空间不再使用JVM内存,而是使用本地内存,减少了OOM风险

类加载器

类加载器是Java虚拟机(JVM)的一部分,用于将类的字节码加载到内存中,并在运行时创建类的对象

  • Bootstrap ClassLoader

    ​ 启动类加载器:最顶层的加载类,主要加载核心类库,也就是我们环境变量下面%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等

  • Extention ClassLoader

    ​ 加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件

  • Application ClassLoader

    加载当前应用的classpath的所有类

自定义类加载器

  • 定义一个类,继承 ClassLoader
  • 重写findClass 方法
  • 使用defineClass() 实例化Class对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MyClassLoad extends ClassLoader {

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class<?> clazz = null;
if (name.equals("com.example.luckdraw.test.TestObject")) {
try {
InputStream inputStream = new FileInputStream("C:\\Desktop\\TestObject.class");
byte[] buff = new byte[inputStream.available()];
inputStream.read(buff);
clazz = defineClass(name, buff, 0, buff.length);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
} else {
clazz = super.findClass(name);
}
return clazz;
}
}

双亲委派原则

委派:当类加载器收到加载任务会先委派给父加载器进行加载(自下向顶为委派)

加载:当父类加载不到此类则丢给子类加载器加载(自顶向下加载)

作用:保证核心类被正确加载(java源码级别的类)

好处:因为类加载后会被缓存,所以保证类的一致性和避免类的冲突

双亲委派原则只是一种规范或约定,并不是Java虚拟机的强制要求。在特定的情况下,用户也可以自定义类加载器,来实现自己的加载逻辑

破坏双亲委派

JDBC

根据类加载机制,当被加载的类引用了另外一个类的时候,虚拟机就会使用加载第一个类的类加载器加载被引用的类

  • JDBC驱动程序通常是由第三方提供的,由Application ClassLoader加载,而且这些驱动程序需要访问JDBC API的内部实现
  • JDBC API是Java的核心类,由Bootstrap ClassLoader加载,因此需要破坏双亲委派

Tomcat

破坏双亲委派:每个Tomcat的WebApp ClassLoader加载自己的目录下的class文件,不会传递给父类加载器

  • 对于各个 webapp中的 classlib,需要相互隔离,不能出现一个应用中加载的类库会影响另一个应用的情况
  • 热部署

类加载过程

加载

将类的字节码文件加载到内存,并方法区对应生成一个Class对象

链接

对加载的类进行校验

  • 验证

    验证二进制流是否符合规范,是否存在安全问题

  • 准备

    为静态变量开辟内存空间并赋予初始值

  • 解析

    将类的符号引用改为直接引用

    符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量

    1
    2
    类:Java.lang.String
    对应描述符:Ljava/lang/String;

    直接引用:直接指向目标的指针

初始化

​ 当一个类被主动使用时,Java虚拟机就会对其初始化

  • 只对static修饰的变量或静态代码块进行初始化
  • 如果存在父类,则优先初始化父类
  • 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行

类加载时机

类没有被主动使用,没有被加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Constant {
public static int a;

static {
System.out.println("a=10");
a = 10;
}
}

public class Test {
public static void main(String[] args) {
// 类被加载但是没有被主动使用,所以没有执行初始化
System.out.println(Constant.class);
}
}

输出 class com.example.demo.model.Constant

主动使用类,JVM会调用类的构造器收集 static修饰的变量和语句进行初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Constant {
public static int a;

static {
System.out.println("a=10");
a = 10;
}
}

public class Test {
public static void main(String[] args) {
// 主动使用类,JVM会调用类的构造器收集 static修饰的变量和语句进行初始化
System.out.println(Constant.a);
}
}

输出
a=10
10

加载时机

  • 创建某个类的实例
  • 访问某个类的静态方法
  • 使用某个类的静态字段
  • 初始化某个类的子类
  • 使用反射机制访问类
  • 程序的启动类(包含main方法)

对象创建过程

  • 使用 new 关键字或者反射机制
  • 根据new的参数在 常量池中定位一个类的符号引用
  • 如果没有找到这个类的符号引用,说明类没有被加载,则进行类的加载,解析和初始化
  • 虚拟机为对象分配内存空间
    • 指针碰撞(内存相对规整的情况,也就是垃圾回收器带内存整理压缩功能的)
    • 空闲链表
  • 对齐填充,对象的大小始终为8的整数倍,原因是便于内存寻址
  • 将分配的内存初始化为0
  • 设置对象头,包括分代年龄,对象的HashCode
  • 调用对象的构造方法

对象结构

Head 头信息

  • hash值:对象的hashCode
  • 分代年龄:默认为0,用于分代年龄计算
  • 锁状态标识位:标记当前对象是否上锁
  • 持有锁的线程:当前对象锁属于哪个线程所有
  • 类型指针:标记当前对象属于哪个Class

对象实例数据

对齐填充:无意义,JVM要求对象大小必须是8字节的整数倍

ClassLoad和Class.forName

  • ClassLoad只是将类加载到JVM
  • Class.forName除了类加载还会执行静态代码块
  • Class.forName还可以对类进行实例化

指令重排序

在代码实际运行时,代码指令可能不按照代码语句顺序执行的。只要程序的结果和顺序性执行代码的结果一致,那么指令的执行顺序就可以不和代码顺序一致,这就是指令重排序

原因:现代处理器架构采用乱序执行方法,在条件允许的情况下,直接执行后面的指令,通过乱序执行技术提高处理器的执行效率

  • 编译器优化的重排序。编译器在不改变单线程程序的语义前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序。现在处理器采用了指令集并行技术,来将多条指令重叠执行。如果不存在依赖性,处理器可以改变语句对应的机器指令的执行顺序
  • 内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

内存屏障

内存屏障(Memory Barrier)是指一组指令,用于控制处理器和内存之间的访问顺序和可见性

作用

  • 阻止屏障两侧的指令重排序
  • 强制缓冲区数据刷新到主内存或强制从主内存加载最新数据到缓冲区

分类

  • 读屏障:在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据
  • 写屏障:在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见

volatile语义中的内存屏障

  1. 可见性:对一个volatile变量的写操作对其他线程的读操作是可见的,即写操作立即刷新到主内存中,读操作从主内存中获取最新的值。
  2. 有序性:对一个volatile变量的写操作不会与之前的写操作发生重排序,也不会与之后的写操作发生重排序。保证了volatile写操作的有序性。
  3. 禁止指令重排序:volatile变量的读写操作都会插入内存屏障,禁止指令重排序。

具体来说,volatile语义的内存屏障包括以下几种屏障:

  • 在每个volatile写操作前插入StoreStore屏障,保证在写操作前的所有写操作对其他线程/CPU可见。
  • 在每个volatile写操作后插入StoreLoad屏障,保证volatile写操作对其他线程/CPU的读操作可见。
  • 在每个volatile读操作前插入LoadLoad屏障,保证volatile读操作之前的读操作完成。
  • 在每个volatile读操作后插入LoadStore屏障,保证volatile读操作后的所有读操作都能看到volatile读操作之前的写操作。

final语义中的内存屏障

防止指令重排序

  • 对于final域,编译器和CPU会遵循两个排序规则:
  1. 新建对象过程中,构造体中对final域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序;(废话嘛)
  2. 初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序;(晦涩,意思就是先赋值引用,再调用final值)
  • 总之上面规则的意思可以这样理解,必需保证一个对象的所有final域被写入完毕后才能引用和读取。这也是内存屏障的起的作用:
  • 写final域:在编译器写final域完毕,构造体结束之前,会插入一个StoreStore屏障,保证前面的对final写入对其他线程/CPU可见,并阻止重排序。
  • 读final域:在上述规则2中,两步操作不能重排序的机理就是在读final域前插入了LoadLoad屏障。
  • X86处理器中,由于CPU不会对写-写操作进行重排序,所以StoreStore屏障会被省略;而X86也不会对逻辑上有先后依赖关系的操作进行重排序,所以LoadLoad也会变省略

@Contended

@sun.misc.Contended 是 Java 8 新增的一个注解,对某字段加上该注解则表示该字段会单独占用一个 缓存行(Cache Line)

这里的缓存行是指 CPU 缓存的存储单元,常见的缓存行大小为 64 字节

注意:JVM 添加 -XX:-RestrictContended 参数后 @sun.misc.Contended 注解才有效

单独使用一个缓存行避免伪共享

为了提高读取速度,每个 CPU 有自己的缓存,CPU 读取数据后会存到自己的缓存里。而且为了节省空间,一个缓存行可能存储着多个变量,即 伪共享。但是这对于共享变量,会造成性能问题:

  • 当一个 CPU 要修改某共享变量 A 时会先 锁定 自己缓存里 A 所在的缓存行,并且把其他 CPU 缓存上相关的缓存行设置为 无效
  • 但如果被锁定或失效的缓存行里,还存储了其他不相干的变量 B,其他线程此时就访问不了 B,或者由于缓存行失效需要重新从内存中读取加载到缓存里,这就造成了 开销所以让共享变量 A 单独使用一个缓存行就不会影响到其他线程的访问。

适用场景:主要适用于频繁写的共享数据上。如果不是频繁写的数据,那么 CPU 缓存行被锁的几率就不多,所以没必要使用了,否则不仅占空间还会浪费 CPU 访问操作数据的时间。

synchronized

synchronized 用的锁标志是存在Java对象头里的

synchronized重量级锁

底层实现

在Java中,每个对象都关联一个 monitor(监视器锁,锁的粒度是对象,如果是静态资源则对应Class对象),在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)。当线程进入synchronized代码块时,它会尝试获取对象的监视器锁。如果该锁未被其他线程占用,则该线程会立即获得该锁,并继续执行synchronized代码块。如果该锁已经被其他线程占用,则当前线程会进入该对象的等待队列中,等待其他线程释放该锁。当其他线程释放该锁时,等待队列中的线程会被唤醒,然后它们会再次尝试获取该锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ObjectMonitor() {
_header = NULL; // 用于存储对象监视器的头部信息,初始化为NULL
_count = 0; // 线程获取锁的次数
_waiters = 0, // 当前等待获取该对象监视器的线程数,初始化为0
_recursions = 0; // 当前线程获取该对象监视器的重入次数,初始化为0
_object = NULL; // 被监视的对象,初始化为NULL
_owner = NULL; // 指向持有ObjectMonitor对象的线程地址
_WaitSet = NULL; // 等待获取该对象监视器的线程组成的双向循环链表,初始化为NULL,表示没有等待线程
_WaitSetLock = 0 ; // 保护等待线程链表的自旋锁,初始化为0
_Responsible = NULL ; // 如果某个线程为了获取该对象监视器而阻塞,在唤醒时这个字段指向阻塞线程的Successor
_succ = NULL ; // 当前线程阻塞时的Successor,初始化为NULL
_cxq = NULL ; // 多线程竞争锁进入时的单向链表,初始化为NULL
FreeNext = NULL ; // 用于释放已经不再需要的对象监视器的单向链表,初始化为NULL
_EntryList = NULL ; // 等待被_owner线程唤醒时的线程节点组成的双向循环链表,初始化为NULL
_SpinFreq = 0 ; // 用于自旋次数的计数,初始化为0
_SpinClock = 0 ; // 用于自旋的时钟,初始化为0
OwnerIsThread = 0 ; // 持有者是否为线程的标记,初始化为0
}

加锁过程

  • 当一个线程尝试进入一个synchronized块时,它会先尝试获取对象监视器的锁
  • 如果锁没有被其他线程持有,那么当前线程立即获得锁,可以继续执行synchronized块中的代码
  • 如果锁已经被其他线程持有,那么当前线程会进入阻塞状态,被放入锁的等待队列中(_WaitSet)
  • 当一个线程释放锁时,ObjectMonitor会将等待队列中(_WaitSet)唤醒一个线程,让其获得锁

释放锁

  • 线程执行完synchronized代码块中的代码,或者发生了异常导致代码块提前退出
  • 执行一个monitor.exit指令,该指令会释放当前线程持有的锁
  • 在释放锁之前,线程会将持有锁的计数减1,如果计数为0,则表示锁被完全释放;如果计数大于0,表示锁被重入了多次
  • 线程释放锁后,JVM 会从等待队列中选择一个线程唤醒,让其获得锁

使用

Synchroinzed 用法

  • synchronized修饰实例方法
  • synchronized修饰静态方法
  • synchronized修饰实例方法的代码块
  • synchronized修饰静态方法的代码块

JDK1.6之后对 synchronized 做了下面三点优化,在之后的JDK版本里性能和 ReentrantLock 不相上下

锁升级

  • 无锁 :对象头开辟 25bit 的空间用来存储对象的 hashcode ,4bit 用于存放对象分代年龄,1bit 用来存放是否偏向锁的标识位,2bit 用来存放锁标识位为01
  • 偏向锁: 在偏向锁中划分更细,还是开辟 25bit 的空间,其中23bit 用来存放线程ID,2bit 用来存放 Epoch,4bit 存放对象分代年龄,1bit 存放是否偏向锁标识, 0表示无锁,1表示偏向锁,锁的标识位还是01
  • 轻量级锁:在轻量级锁中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00
  • 重量级锁: 在重量级锁中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为11

无锁

无锁(也称为乐观锁)是指当多个线程同时访问共享资源时,没有线程进行阻塞,通常是使用CAS机制实现的

偏向锁

当一个线程第一次访问共享资源时,JVM会将锁的对象头设置为偏向模式,并将线程ID记录在对象头中。在接下来的访问中,线程只需要检查对象头中的线程ID是否是自己,如果是,则无需进行锁的获取操作

轻量级锁(自旋锁)

轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能

轻量级锁的获取主要由两种情况

  • 当关闭偏向锁功能时 -XX:-UseBiasedLocking=false

  • 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁

    当多个线程同时访问同一个共享资源时,JVM会将锁的对象头设置为轻量级锁模式,并尝试使用CAS + 自旋操作将对象头中的锁指针指向当前线程,如果CAS操作成功,则当前线程获得锁

重量级锁

重量级锁显然,此忙等是有限度的(自旋,有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁,当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起并加入等待队列,等待锁释放后被唤醒

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态

锁消除

锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行

比如一个使用 synchronized 修饰的方法,它并没有访问共享资源,不存在并发问题,此时没有资源竞争自然就不需要加锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Synchroinzed {

/**
* 逃逸分析
* 1.没有共享数据竞争
* 2.没有返回值,方法体内创建的对象直接在栈上内存分配,其他线程无法访问到,不存在线程安全问题
*/
public synchronized void test() {
System.out.println(Thread.currentThread().getName() + ":" + System.currentTimeMillis());
try {
TimeUnit.SECONDS.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> new Synchroinzed().test()).start();
}
}
}

锁粗化

将多次连续的同步块合并为一次更大的同步块,从而减少锁竞争的次数,提高程序的执行效率

如果JVM检测到有连续的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class Synchroinzed {

public void test() {
System.out.println(Thread.currentThread().getName() + ":" + System.currentTimeMillis());
try {
TimeUnit.MILLISECONDS.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
Synchroinzed sync = new Synchroinzed();

// 创建10个线程
for (int i = 0; i < 5; i++) {
new Thread(() -> {
for (int j = 0; j < 5; j++) {
// 连续对同一对象加锁解锁
synchronized (sync) {
sync.test();
}
}
}).start();
}
}
}

线程编号输出相对规整,说明做了锁粗话,但还是建议放在循环体外面
Thread-0:1650645031133
Thread-0:1650645031183
Thread-0:1650645031233
Thread-0:1650645031283
Thread-0:1650645031333
Thread-4:1650645031383
Thread-4:1650645031433
Thread-4:1650645031483
Thread-4:1650645031533
Thread-4:1650645031583
Thread-3:1650645031633
Thread-3:1650645031683
Thread-3:1650645031733
Thread-3:1650645031783
Thread-3:1650645031833
Thread-2:1650645031883
Thread-2:1650645031933
Thread-2:1650645031983
Thread-2:1650645032033
Thread-2:1650645032083
Thread-1:1650645032133
Thread-1:1650645032183
Thread-1:1650645032233
Thread-1:1650645032283
Thread-1:1650645032333

JIT 即时编译器

JIT编译器(Just-In-Time Compiler)是Java虚拟机(JVM)中的一种编译器,它的主要作用是将Java字节码(bytecode)实时编译为本地机器码(native code),以提高程序的执行效率。

JIT编译器的工作原理如下:

  1. 解释器解释字节码:当Java程序被执行时,JVM会先将字节码交给解释器进行解释执行。解释器将逐条解释执行字节码指令,将其转换为对应的机器指令并执行。这种方式的好处是可移植性强,适用于任何平台。
  2. 监控热点代码:JIT编译器会监控程序的执行情况,识别出频繁执行的热点代码。热点代码通常是被多次执行的方法或循环体。
  3. 编译热点代码:一旦识别出热点代码,JIT编译器会将其编译为本地机器码。与解释执行相比,本地机器码的执行速度更快。
  4. 优化编译:在编译过程中,JIT编译器会进行一些优化操作。例如,方法内联、循环展开、去除无用代码等。这些优化措施可以进一步提高程序的执行效率。

热点代码:

  • 被多次调用的方法
  • 被多次执行的循环体

缺点:

  • 内存占用:JIT编译器需要将编译后的本地机器码保存在内存中,这会增加内存的占用。
  • 编译时间:JIT编译器需要一定的时间来进行编译,这可能会导致程序的启动时间延长。

推荐文章 美团技术沙龙

GC (Garbage Collectors)

内存区域划分

Eden

基本上所有新建的对象都在这个区域

因为还存在大对象直接进入老年代和栈上内存分配情况

Eden:Survivor_from:Survivor_to 8:1:1

Survivor

Minor GC 后移入的存活区, 分为Survivor_fromSurvivor_to两个区域,Survivor区域在GC时采用复制算法进行对象淘汰,当对象多次GC(默认15次)后存在的仍然存活就会进入老年代

Eden:Survivor_from:Survivor_to 8:1:1

Old

老年代,长期存活的对象存放的区域,只有Full GC才会清理这个区域的对象

新生代:老年代 1:2

MinorGC、MajorGC 、FullGC和Mixed GC

Minor GC:从年轻代(Eden和survivor区域)回收内存,这个区域的对象生存周期短,发生gc的次数比较频繁,回收速度比较快,一般采用复制算法

Major GC: 是清理老年代

Full GC:清理整个堆空间,包含年轻代,老年代,永久代(方法区和元空间)的垃圾回收,一般耗时比较长,因此必须降低FullGC的频率

  • 老年代空间不足,Survivor晋升对象大小大于老年代剩余空间
  • Minor GC晋升到旧生代的平均大小大于老年代的剩余空间
  • Metaspace区内存达到阈值(20M)
  • 达到收集器收集的阈值(90%)

Mixed GC:对新生代和老年代同时进行垃圾回收,将新生代和部分老年代一起进行回收,以减少SWT的时间

stop the word 现象 GC线程执行垃圾对象回收而挂起所有工作线程,程序会出现卡顿现象

内存分配策略

优先分配到Eden

基本上几乎所有新建的对象都会被分配到 Eden区域,Eden区域每次发生GC都会被清除

大对象直接分配到老年代

1
2
-XX:PretenureSizeThreshold=6M 指定多大的对象直接放进老年区
生效的垃圾收集器 Serial,ParNew,G1

长期存活的对象分配到老年代

1
2
默认是15次GC后进入老年代,可以有参数设置,也非必要15次Minor GC才能进入老年代,因为还有动态对象年龄分配
-XX:MaxTenuringThreshold=10

动态对象年龄判断

1
Survivor区中相同年龄的对象大小的总和大于Survivor空间的一半,大于或等于该年龄的对象可以直接进入老年代

空间分配担保

触发场景:Eden区域有对象要进入Survivor区域,Survivor区域满了

1
2
1.6后默认开启,不用手动设置
老年代的连续空间大于新生代对象总大小或者历次新生代对象晋升老年代平均大小就会进行Minor GC(将survivor区域复制到老年代),否则将进行Full GC

逃逸分析与栈上分配

1
2
3
-XX:+DoEscapeAnalysis 1.6后默认开启
逃逸分析:对象仅在当前作用于下有效(成员变量会发生逃逸)
为了提高GC的回收效率,对象实例的内存分配不一定必须存在于堆区中,还可采用堆外分配。而最常见的堆外分配就是采用逃逸分析筛选出未发生逃逸的对象,在栈帧中分配内存空间

判断对象是否死亡

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡,当一个对象不再被任何存活的对象继续引用的时候,就可以宣判为死亡了

分代内存区域划分

Eden : Survivor1 : Survivor2 = 8 : 1 : 1

新生代:老年代 = 1:3

引用计数法

  • 引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
  • 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收

优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。

缺点

  • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销
  • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
  • 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。

在 Netty 的直接内存缓冲区中有实际落地场景

可达性分析算法

可达性分析算法:也可以称为根搜索算法、追踪性垃圾收集

  • 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生
  • 相较于引用计数算法,这里的可达性分析就是Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)

实现思路

  • 可达性分析算法是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
  • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

GC ROOT

  • 虚拟机栈中局部变量表引用的对象
  • 方法区中静态变量引用的对象
    • 比如:Java类的引用类型静态变量
  • 方法区中常量引用的对象
    • 比如:字符串常量池(StringTable)里的引用
  • 所有被同步锁synchronized持有的对象
  • 系统类加载器

总之只要不会被GC所回收的对象都能被当作GC ROOT

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合 (比如Minor GC 使用老年代对象作为 GC Root)

注意

  • 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
  • 这点也是导致GC进行时必须Stop The World的一个重要原因。即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的

三色标记

Java的三色标记法是一种垃圾收集算法,它的核心思想是将对象分为三种颜色来标记其状态,从而决定哪些对象可以被回收。

  1. 白色(White,垃圾对象):表示尚未扫描的对象。在初始标记阶段,所有的对象都是白色的。
  2. 灰色(Gray):没有完全扫描的对象。
  3. 黑色(Black):完全扫描的对象,对象是可达的,不会被垃圾回收。

但仅仅将对象划分成三个颜色还不够,真正关键的是:实现根可达算法的时候,将整个过程拆分成了初始标记并发标记重新标记并发清除四个阶段

  • 初始标记阶段,指的是标记 GCRoots 直接引用的节点,将它们标记为灰色,这个阶段需要 STW
  • 并发标记阶段,指的是从灰色节点开始,去扫描整个引用链,然后将它们标记为黑色,这个阶段不需要 STW
  • 重新标记阶段,指的是去校正并发标记阶段的错误,这个阶段需要(漏标) STW
  • 并发清除,指的是将已经确定为垃圾的对象清除掉,这个阶段不需要 STW

多标

多标问题指的是原本应该回收的对象,被多余地标记为黑色存活对象,从而导致该垃圾对象没有被回收

原因:多标问题会出现,是因为在并发标记阶段,有可能之前已经被标记为存活的对象,其引用被删除,从而变成了不可达对象

多标

多标问题会导致内存产生浮动垃圾,但好在其可以再下次 GC 的时候被回收,因此问题还不算很严重

漏标

原本存活的对象,被遗漏标记为黑色,从而导致该对象被错误回收

原因:并发标记和对象引用关系的变化导致的

条件

  • 至少一个黑色对象引用了白色对象
  • 所有的灰色对象在扫描完成之前断开对白色对象的引用

漏标

解决漏标

  • CMS采用的是 增量更新
  • G1采用的是 原始快照

对象的 finalization 机制

Finalization机制是一种对象销毁前的回调函数,通常通过重写对象的 finalize() 方法来实现,finalize()只会被GC线程调用一次

  • 当一个垃圾对象被回收前,总会先调用这个对象的finalize()方法。
  • 它允许在对象被垃圾回收之前执行一些特定的清理操作(已不推荐使用)。
1
2
// 等待被重写
protected void finalize() throws Throwable { }

不主动调用finalize()方法

  • finalize() 方法中可能导致对象复活,使其从垃圾状态恢复为存活状态。
  • finalize() 方法的执行时机是不确定的,由垃圾回收线程决定
  • 一个糟糕的 finalize() 方法可能严重影响垃圾回收的性能。比如finalize是个死循环

对象的生命周期

Finalization机制存在的情况下,Java对象可以处于三种状态:

  1. 可触及的(Reachable):从根节点出发,可以访问到这个对象,它处于活动状态
  2. 可复活的(Finalizable):对象的所有引用都被释放,但对象有可能在 finalize() 方法中复活,它处于一种临时状态
  3. 不可触及的(Unreachable):对象的 finalize() 方法已被调用,且没有复活,对象进入不可触及状态,不可再被引用或复活

对象的自救

要判断一个对象是否可回收,需要经历两次标记过程:

  • 如果对象无法从任何根节点访问到,首先进行第一次标记
  • 进行筛选,判断对象是否需要执行 finalize() 方法:
    • 如果对象没有重写 finalize() 方法,或者 finalize() 方法已经被调用过,视为不需要执行,对象被判定为不可触及
    • 如果对象重写了 finalize() 方法且未执行过,对象被插入到Finalizer队列中,由Finalizer线程触发其finalize()方法执行

finalize() 方法是对象逃脱死亡的最后机会。如果在 finalize() 方法中与引用链上的任何一个对象建立了联系,对象将被移出"即将回收"集合。然后,对象会再次处于没有引用的状态,此时 finalize() 方法不会再次被调用,对象变为不可触及状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* 测试Object类中finalize()方法,即对象的finalization机制。
*
*/
public class CanReliveObj {
public static CanReliveObj obj;//类变量,属于 GC Root


//此方法只能被调用一次
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("调用当前类重写的finalize()方法");
obj = this;//当前待回收的对象在finalize()方法中与引用链上的一个对象obj建立了联系
}


public static void main(String[] args) {
try {
obj = new CanReliveObj();
// 对象第一次成功拯救自己
obj = null;
System.gc();//调用垃圾回收器
System.out.println("第1次 gc");
// 因为Finalizer线程优先级很低,暂停2秒,以等待它
Thread.sleep(2000);
if (obj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
System.out.println("第2次 gc");
// 下面这段代码与上面的完全相同,但是这次自救却失败了
obj = null;
System.gc();
// 因为Finalizer线程优先级很低,暂停2秒,以等待它
Thread.sleep(2000);
if (obj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

输出
1次 gc
调用当前类重写的finalize()方法
obj is still alive
2次 gc
obj is dead

如果注释掉finalize()方法,输出结果:

1
2
3
4
1次 gc
obj is dead
2次 gc
obj is dead

垃圾回收算法

垃圾清除阶段

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。目前在JVM中比较常见的三种垃圾收集算法是

  • 标记-清除算法(Mark-Sweep)
  • 复制算法(Copying)
  • 标记-压缩算法(Mark-Compact)

标记-清除算法

空闲链表 空间碎片

执行过程

用于在堆中清理不再使用的对象。它的执行过程可以分为两个阶段 标记 阶段和 清除 阶段

  1. 标记阶段(Mark Phase)
    • 在标记清除算法的第一阶段,垃圾收集器从根对象开始,遍历整个对象图,并标记所有可以从根对象访问到的对象。
    • 垃圾收集器会将已标记的对象标记为"存活",而未标记的对象被视为"垃圾"。
  2. 清除阶段(Sweep Phase)
    • 在清除阶段,垃圾收集器遍历特定的内存区域,将未标记的对象(即垃圾对象)进行回收,释放它们占用的内存空间。
    • 清除操作通常是通过将对象所在的内存标记为空闲来实现的,使其可以用于后续的内存分配。

缺点

  • 效率低下:需要找到未被标记的对象进行回收
  • 停顿时间长:垃圾回收期间停止整个应用程序的运行(STW,Stop-The-World),以便进行标记和清除操作
  • 内存碎片:使用空闲链表标记已回收的内存空间,内存不连续,使用效率低

复制算法

指针碰撞 需要两倍内存空间

复制算法被广泛应用于新生代垃圾收集器中。它的核心思想是:

  1. 将内存分成两个相等大小的区域
  2. 然后将活跃对象复制到一个新的内存区域(Eden区和Survivor的From和To区)
  3. 最后将不活跃的对象清除掉

优缺点

优点

  • 实现简单,运行高效
  • 活跃对象被复制后,会直接释放整块内存区域,不存在内存碎片

缺点

  • 需要两倍的内存空间(空间换时间)
  • 其他对象要更新移动对象的引用关系,有额外的性能开销

标记-压缩算法

指针碰撞 整理空间碎片,效率更低

背景

  • 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法
  • 标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生
  • 1970年前后,G.L.Steele、C.J.Chene和D.s.Wise等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本

实现

  • 对象标记完成后,存活对象往内存的一端移动,非存活对象往反方向移动
  • 移动存活对象时,需要更新所有引用这些对象的引用地址
  • 找到存活对象的内存的临界点,释放临界点外的内存

比较

  • 标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法
  • 二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策
  • 可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销

优缺点

优点

  • 消除了标记-清除算法当中存在内存碎片问题
  • 消除了复制算法当中,内存使用翻倍成本

缺点

  • 效率相对较低,标记-整理算法要低于复制算法
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整对象引用的地址
  • 移动过程中,需要全程暂停用户应用程序,造成STW

分代收集算法

分代收集算法并不是一种新的算法,而是根据场景不同使用不同的算法,比如新生代中使用复制算法,老年代中使用标记-清除,标记-压缩算法

目前几乎所有的GC都采用分代收集算法执行垃圾回收的

在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点

  • 年轻代(Young Gen)
    • 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁
    • 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解
  • 老年代(Tenured Gen)
    • 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁
    • 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现
      • 标记阶段的开销与存活对象的数量成正比
      • 清除阶段的开销与所管理区域的大小成正相关
      • 压缩阶段的开销与存活对象的数据成正比
  • 以HotSpot中的CMS回收器为例,CMS是基于标记-清除算法实现的,对于对象的回收效率很高。对于碎片问题,CMS采用基于标记-压缩算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理。
    分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代​

垃圾回收算法小结

  • 效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存
  • 而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段
标记清除标记整理复制
速率中等最慢最快
空间开销少(但会堆积碎片)少(不堆积碎片)通常需要活对象的2倍空间(不堆积碎片)
移动对象

评估GC的性能指标

指标说明
吞吐量运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间)
垃圾收集开销吞吐量的补数,垃圾收集所用时间与总运行时间的比例
暂停时间执行垃圾收集时,程序的工作线程被暂停的时间
收集频率相对于应用程序的执行,收集操作发生的频率
内存占用Java堆区所占的内存大小
快速一个对象从诞生到被回收所经历的时间
  • 吞吐量、暂停时间、内存占用这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。
  • 这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。
  • 简单来说,主要抓住两点:
    • 吞吐量
    • 暂停时间

吞吐量(throughput)

  1. 吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间 /(运行用户代码时间+垃圾收集时间)
    • 比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
  2. 这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的
  3. 吞吐量优先,意味着在单位时间内,STW的时间最短:0.2+0.2=0.4

img

暂停时间(pause time)

  1. 暂停时间是指一个时间段内应用程序线程暂停,让GC线程执行的状态。
    • 例如,GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动的
  2. 暂停时间优先,意味着尽可能让单次STW的时间最短:0.1+0.1 + 0.1+ 0.1+ 0.1=0.5,但是总的GC时间可能会长

img

垃圾回收器

回收器特点线程模型新/老年代回收算法
Serial单线程串行收集器串行收集器新生代复制算法
ParNew多线程并行Serial收集器并行收集器新生代复制算法
Parallel Scavenge并行吞吐量优先收集器并行收集器新生代复制算法
Serial OldSerial单线程收集器老年代版本串行收集器老年代标记-压缩
CMS(Concurrent Mark Sweep)并行最短停顿时间收集器并发收集器老年代标记-清除
Parallel OldParallel Scavenge并行收集器老年代版本并行收集器老年代标记-压缩
G1面向局部收集和基于Region内存布局的新型低延时收集器并发/并行收集器新生代/老年代三色标记

JDK8 默认垃圾收集器是Parallel Scavenge+Parallel Old

Serial

新生代 串行执行 复制算法

它是一种单线程的垃圾回收器,意味着它在执行垃圾回收操作时只使用一个线程,因此在垃圾回收过程中会暂停应用程序的执行,吞吐量较差

  • 单线程执行:它在执行垃圾回收操作时只使用一个线程。这使得它在多核处理器上的性能表现相对较差。因为它是串行的,所以它执行垃圾回收时会暂停应用程序的所有线程
  • 复制算法: Serial垃圾回收器通常用于新生代(Young Generation)的垃圾回收。在新生代中,它使用复制算法回收垃圾对象
  • 适用于客户端应用:由于Serial垃圾回收器的特点,它通常用于客户端应用程序,如桌面应用程序或移动应用程序,对吞吐量要求不高的场景
  • 最悠久的垃圾回收器:Serial收集器是最基本、历史最悠久的垃圾收集器了,JDK1.3之前回收新生代唯一的选择
  • 指定Serial收集器-XX:+UseSerialGC

Serial Old

串行执行 标记-整理算法

除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。Serial old收集器同样也采用了串行回收和"Stop the World"机制,只不过内存回收算法使用的是 标记-压缩算法

  • 简单高效:对于单核CPU来说,Serial收集器没有线程切换的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。运行在Client模式下的虚拟机是个不错的选择

  • 标记-整理算法: Serial Old垃圾收集器使用标记-整理(Mark-Sweep-Compact)算法,有助于减少内存碎片,提高内存利用率

  • 不适用于高吞吐量需求:Serial Old垃圾回收器是单线程的,不适合要求高吞吐量的大型服务器应用程序,因为它可能导致较长的垃圾回收停顿时间,影响应用程序的响应性

  • 指定Serial收集器-XX:+UseSerialGC 等价于新生代用Serial GC,且老年代用Serial Old GC

Server模式下的用途

Serial Old是运行在 Client模式 下默认的老年代的垃圾回收器,Serial Old在 Server模式 下主要有两个用途

  • 与新生代的Parallel Scavenge配合使用
  • 作为老年代CMS收集器内存整理的方案

ParNew

新生代 多线程并行回收 复制算法 提高吞吐量 配合CMS使用

  • 多线程执行: ParNew是Serial的多线程版本,利用多个处理器核心并行执行垃圾回收操作,从而提高了吞吐量

  • 适用于多核处理器:ParNew是多线程的垃圾回收器,因此在多核处理器上表现良好,适用于需要更高吞吐量的应用程序

  • 与CMS配合使用:早期这种组合旨在最大程度地减少应用程序的停顿时间,适用于需要低延迟的应用

  • 启用选项

    • 设置新生代垃圾收集器,不影响老年代 -XX:+UseParNewGC
    • 限制并行线程数,默认开启和CPU核心相同的线程数,-XX:ParallelGCThreads

ParNew 回收器与 Serial 回收器比较

由于ParNew收集器基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比Serial收集器更高效?

  1. ParNew收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
  2. 但是在单个CPU的环境下,ParNew收集器不比Serial收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
  3. 除Serial外,目前只有ParNew GC能与CMS收集器配合工作

Parallel Scavenge

复制算法 并行回收 吞吐量优先 自适应调节策略

吞吐量 = 程序运行时间 /(程序运行时间 + GC时间)

  • 注重提供高吞吐量和可控制的垃圾回收延迟
  • 吞吐量优先
    • 尽可能地减少垃圾回收停顿时间的同时,也更关注程序的总吞吐量
    • 高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务

自适应调节策略

开启后就不需要人工指定新生代的大小(-Xmn)、Eden 与 Survisor 区的比例(-XX:SurvivorRatio)、直接晋升老年代对象大小(-XX:PretenureSizeThreshold

  • -XX:+UseParallelGC: 手动指定年轻代使用Parallel并行收集器执行内存回收任务
  • -XX:ParallelGCThreads:设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能
  • -XX:MaxGCPauseMillis: 设置垃圾收集器最大停顿时间(即STW的时间),单位是毫秒 (设置越小堆空间可能越小,GC越频繁
  • -XX:GCTimeRatio: 垃圾收集时间占总时间的比例,即等于 1 / (N+1) ,用于衡量吞吐量的大小
    • 取值范围(0, 100)。默认值99,也就是垃圾回收时间占比不超过1
    • 与前一个-XX:MaxGCPauseMillis参数有一定矛盾性,STW暂停时间越长,Radio参数就容易超过设定的比例
  • -XX:+UseAdaptiveSizePolicy 设置Parallel Scavenge收集器具有自适应调节策略
    • 在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点

Parallel Old

多线程 标记-压缩

适合场景:配合 Parallel Scavenge 运行在不太关注停顿时间的后台计算任务

  • 标记-整理

  • 自适应调节

    • 调整垃圾回收线程
    • 调整吞吐量
  • 多线程执行:Java 1.6 用户代替老年代Serial Old单线程收集器

  • 吞吐量优先:设计目标是提供高吞吐量。它在尽可能减少垃圾回收停顿时间的同时,更关注程序的总吞吐量

  • 不适用于低延迟需求:由于Parallel Old更关注的是吞吐量(吞吐量优先),它不适合对低停顿时间有极高要求的应用程序

  • 在Java8中,默认是此垃圾收集器。

参数

-XX:+UseParallelGC:手动指定年轻代使用Parallel并行收集器执行内存回收任务

-XX:+UseParallelOldGC:手动指定老年代都是使用并行回收收集器

  • 分别适用于新生代和老年代
  • 上面两个参数分别适用于新生代和老年代。默认jdk8是开启的。默认开启一个,另一个也会被开启。(互相激活)

CMS

已经被ZGC干掉 最求停顿时间和用户体验 第一款并行并发回收器 主打停顿时间 三色标记 标记-清除

CMS 旨在减小垃圾回收的停顿时间(在JDK1.5时期推出),适用于那些需要低延迟和响应性能的应用程序。它是第一款真正意义上的并发收集器,第一次实现了垃圾收集线程与用户线程同时工作,并且使用的是 标记-清除 算法

优点

  • 低停顿时间:CMS与用户线程并发工作,使得在标记阶段应用程序的停顿时间极短
  • 并发执行:允许用户线程和垃圾回收线程同时执行,缩短SWT时间
  • 可控停顿时间:可设置期望最大的停顿时间,控制程序的停顿时间,提高用户体验
  • 适用于大堆:由于其并发特性,CMS能够高效地处理大规模内存,避免出现过长的停顿

缺点

  • 内存碎片:标记-清除算法导致内存碎片,随着时间推移,可能会累积,降低内存的有效利用率
  • CPU开销:垃圾回收器线程与应用程序线程竞争CPU资源,这可能会影响应用程序的整体性能
  • 无法处理浮动垃圾:在并发标记阶段,用户线程和垃圾收集线程同时运行或者交叉运行。如果产生新的垃圾对象,CMS将无法对其进行标记
  • 需要整理内存碎片:内存碎片问题需要委托给Serial Old来处理,随着堆的增大,可能会导致较长的停顿时间

三色标记

使用三色标记判断对象是否垃圾对象的过程

三色

  • 黑色(Black):这些对象是被标记为可达的,不是垃圾对象。
  • 灰色(Gray):这些对象是初始标记和并发标记阶段被标记为可达的,但还未经过重新标记。它们需要重新标记后才能确定是否为垃圾对象。
  • 白色(White):这些对象是未被标记为可达的,被认定为垃圾对象,将在清理阶段被回收。

过程

  • 初始标记:CMS首先对根对象进行标记,将它们标记为灰色。这是一个STW(Stop-The-World)的短暂停顿阶段,用于确定哪些对象是直接可达的。这些被标记的对象是灰色
  • 并发标记:GC线程与用户线程并行执行,继续标记从根对象可达的对象,以及它们所引用的对象,直到无法再继续标记新对象为止。这些被标记的对象是黑色
  • 重新标记:在重新标记阶段,CMS对并发标记期间发生状态变化的对象进行修正,例如在并发标记期间某些对象可能由于应用程序活动而变成了可达状态。这一阶段需要一次STW停顿,但持续时间通常很短
  • 并发清理:GC线程与用户线程并行执行,清理白色对象

参数

参数描述
-XX:+UseConcMarkSweepGC手动启用CMS垃圾回收器,执行内存回收任务。开启此参数会自动启用ParNew(Young区)+CMS(Old区)+Serial Old(Old区备选方案)的组合。
-XX:CMSInitiatingOccupanyFraction设置触发CMS回收的堆内存使用率阈值。JDK5及以前版本默认值为68%,JDK6及以上版本默认值为92%。可以根据内存增长情况调整,以优化性能。
-XX:+UseCMSCompactAtFullCollection指定在执行完Full GC后对内存空间进行压缩整理,以避免内存碎片。注意,这会增加停顿时间。
-XX:CMSFullGCsBeforeCompaction设置在执行多少次Full GC后对内存空间进行压缩整理。
-XX:ParallelCMSThreads设置CMS的线程数量。
-XX:MaxGCPauseMillis设置最大停顿时间。
其他信息CMS默认启动的线程数是 (ParallelGCThreads + 3) / 4,其中ParallelGCThreads是年轻代并行收集器的线程数,这可能会影响应用程序性能。

漏标解决

增量更新

漏标:在并发标记阶段,某些对象由于并发执行和对象状态的变化,未能被正确标记为可达对象

为了应对漏标问题,CMS采用了 增量更新(Incremental Update)方法来解决:

  • 记录状态变化:并发标记阶段,如果某个对象从可达状态变为不可达状态(例如,被删除或修改),CMS会详细记录这个状态变化
  • 重新标记阶段:重新标记阶段,CMS会根据记录的状态变化信息,有针对性地重新检查那些发生变化的对象,并确保它们被正确地标记为不可达状态

G1

G1详解

动态Region管理 无传统分代 优先级回收 可控停顿时间 三色标记 标记整理

G1垃圾收集器采用一种非传统的内存布局方式,它不再将堆内存严格划分为新生代和老年代,而是将整个堆内存划分为大小相等的逻辑内存块,称为"Region"。每个Region都是逻辑上连续的内存段,其具体大小会根据堆的实际大小而定,通常在1MB到32MB之间,而且必须是2的幂次方大小。与传统内存布局不同的是,G1不再要求相同类型的Region在物理内存上相邻,而是允许通过动态分配来实现逻辑上的连续性。

G1垃圾收集器的核心思想是跟踪每个Region中垃圾的堆积情况,并根据预设的垃圾回收时间目标来确定回收的优先级。这使得G1能够避免全堆垃圾回收,而是选择回收优先级最高的区域。这种方式能够显著减少程序停顿(stop-the-world)的时间,使其更加短暂和可预测。同时,G1垃圾收集器在有限的时间内能够获得最高的垃圾回收效率。

分区Region

G1 垃圾收集器将堆内存划分为若干个 Region,每个区域只扮演其中一个角色,包括 Eden区Survivor区Old区。这种划分允许新生代的Region在需要时直接转化为老年代的Region

H区(Humongous):这个特殊区域专门用于存放巨大对象。G1垃圾回收器将对象大小超过Region容量的 50%以上 的对象标记为巨大对象。在其他垃圾回收器中,这类巨大对象通常会被分配到老年代,但这可能导老年代频繁GC

如果一个H区无法容纳一个巨型对象,G1会寻找连续的H分区来存储该对象。如果找不到足够的连续H区,G1可能会被迫触发 Full GC

Remember Set

在分代算法的场景下,线程执行的过程中,对象可能存储在新生代,也可能在老年代。那么,如果对象之间的引用关系,大概率会存在对象 跨代引用

在触发新生代GC 的时候,由于 GC Roots 到 E 和 G 是不可达的,那么 E 和 G 将会被当作垃圾对象回收。导致 H 和 J 指向的地址不再是存放 E 和 G,并且是不确定的,这将会造成程序崩溃

Remember Set主要用于帮助G1垃圾回收器跟踪对象之间的引用关系

在G1垃圾回收器中,Remember Set 用于记录哪些非收集区域的对象引用了收集区域的对象。具体来说,在新生代GC中,Remember Set 记录哪些老年代对象(非收集区域)引用了新生代对象(收集区域)

作用

  • 跟踪跨区域引用关系:记录其他区域对象对本区域对象的引用

  • 辅助垃圾回收:使用Remembered Set,G1可以更快地确定哪些区域与其他区域有较少的交互引用,从而回收收益更大的内存区域

写屏障

写屏障是一个机制(类似AOP),它的目的是在写操作发生时,通知垃圾回收器,以便更新记忆集。当一个对象引用另一个对象,或者引用关系被取消时,写屏障会记录这些变化(记录原始快照)

G1中写屏障的一般工作流程:

  1. 当对象引用发生变化时,例如,一个对象引用了另一个对象,JVM会在执行此引用变化的地方插入写屏障代码
  2. 写屏障代码负责将这个引用变化记录到相应的记忆集中,以便在垃圾回收时可以找到受影响的对象
  3. 垃圾回收器描这些记忆集,防止对象被错误标记

写屏障用于Rset记录对象间的引用,不直接修改Rset是因为存在并发修改,是把脏卡片放到队列中慢慢更新的

JVM注入的一小段代码,用于记录指针变化(类似AOP记录原始快照STAB)

1
2
// 假设 A 是老年代,B是新生代的跨区引用
A.b = B
  • 当指针更新时
    • 将A对应的 Card 标记为 Dirty Card
    • 将 Card 存入 Dirty Card Queue
    • 队列有白/绿/黄/红四个颜色

Dirty Card Queue

  • 白色:RSet修改缓慢,一切正常
  • 绿色:Refinement线程(优化线程池)开始被激活,开始更新RS
    • 具体操作:正常的从队列中拿出DirtyCard,并更新到对应的RSet中
  • 黄色:当产生藏卡片的速度非常快,所有的Refinement线程开始激活
    • 目的:全力以赴的把队列排空,目标就是不要让队列太慢
  • 红色:应用线程也参与排空(清理Dirty Card Queue)队列的操作
    • 目的:GC线程和用户线程一起清空,使得Refinement能够得到及时的执行完毕

Card

卡片

拆分Region, 堆内存最小可用粒度

一个 Card Table 将一个 Region 在逻辑上划分为若干个固定大小(介于128到512字节之间)的连续区域,每个区域称之为卡片 Card。Card作用是跟踪区域内对象引用关系的变化,帮助确定哪些区域需要进行垃圾回收

具体而言,当一个对象引用了另一个对象时,G1垃圾回收器会将对应的卡片标记为 脏卡片,表示该卡片中的对象引用发生了变化。当垃圾回收器需要进行垃圾回收时,它可以快速检查哪些卡片是脏的,从而确定需要扫描和处理的区域

  • 写屏障(记录原始快照):Card还用于实现G1的写屏障机制。在对象引用关系变化时,将变化写入脏卡片后加入Dirty Card Queue,进行对象关系更新
  • 并发标记:对象引用关系更新,对应的卡片会被标记为脏卡片,G1通过扫描卡片来判断哪些区域中的对象是活动的,以便在标记过程中准确地识别垃圾对象

Card Table

Card Table是一种数据结构,由一组字节组成,每个字节对应一个Card。用于跟踪对象之间的引用关系。它的主要目的是在垃圾回收过程中快速确定哪些对象是活跃的,从而只对这些对象执行回收操作,以提高回收效率

每个Card代表一个固定大小的内存区域,通常是一个内存页。默认情况下,所有的Card都被标记为未引用状态

当一个线程修改某个Region内部的引用时,它会通知Card Table,并更新相应的Card。这意味着当引用发生变化时,相关的Card将被标记为已引用状态,对应的Card的字节值会被标记为"1",表示该地址空间中的对象是活跃的

为了更高效地跟踪引用变化,G1垃圾回收器将内存划分为许多Region,并为每个Region维护一个对应的Card Table。这样,当进行垃圾回收时,只需要处理与已标记为引用的Card相关的对象,而无需扫描整个堆

Collect Set

Collect Set(CSet)是指,在回收阶段,由G1垃圾回收器选择的待回收的Region集合,G1的目标是在限定的停顿时间内回收尽可能多的垃圾。因此,它会基于每个区域的存活对象的数量和其他历史数据来选择哪些区域应该被包含在Collection Set中。优先选择那些包含大量垃圾的区域可以确保在回收时获得最大的空间回报

G1解决漏标

原始快照 STAB

STAB解决漏标问题的基本思想是,在进行并发标记的同时,跟踪并记录在并发标记过程开始之后发生的所有写操作(通过写屏障)。当发生写操作时(对象引用发生变化),STAB会将相关的对象引用信息记录在一个日志中,该日志称为 Remembered Set(记忆集)

当G1回收器需要对某个Region进行垃圾回收时,它会首先检查该Region对应的Remembered Set中的引用信息,重新检查对象的引用情况。从而将漏标的对象进行正确标记,避免错误地回收

调参

选项/默认值说明
-XX:+UseG1GC使用 G1 (Garbage First) 垃圾收集器
-XX:MaxGCPauseMillis=n设置最大GC停顿时间(GC pause time)指标(target). 这是一个软性指
标(soft goal), JVM 会尽量去达成这个目标.
-XX:InitiatingHeapOccupancyPercent=n启动并发GC周期时的堆内存占用百分比. G1之类的垃圾收集器用它来
触发并发GC周期,基于整个堆的使用率,而不只是某一代内存的使用比.
值为 0 则表示"一直执行GC循环". 默认值为 45.
-XX:NewRatio=n新生代与老生代(new/old generation)的大小比例(Ratio). 默认值为 2.
-XX:SurvivorRatio=neden/survivor 空间大小的比例(Ratio). 默认值为 8.
-XX:MaxTenuringThreshold=n提升年老代的最大临界值(tenuring threshold). 默认值为 15.
-XX:ParallelGCThreads=n设置垃圾收集器在并行阶段使用的线程数,默认值随JVM运行的平台
不同而不同
-XX:ConcGCThreads=n并发垃圾收集器使用的线程数量. 默认值随JVM运行的平台不同而不同.
-XX:G1ReservePercent=n设置堆内存保留为假天花板的总量,以降低提升失败的可能性. 默认
值是 10.
-XX:G1HeapRegionSize=n使用G1时Java堆会被分为大小统一的的区(region)。此参数可以指定
每个heap区的大小. 默认值将根据 heap size 算出最优解. 最小值为
1Mb, 最大值为 32Mb.

收集过程

  • 初始标记:这是一个短暂的停顿阶段,主要标记与GC Roots 直接关联的对象,例如Remembered Set 中的引用。这个阶段的目标是快速确定哪些对象是存活的。
  • 并发标记:在这个阶段,G1垃圾回收器从GC Roots开始进行可达性分析,找出要回收的对象。与用户程序并发执行,不会导致长时间的停顿。它使用写屏障记录STAB(Snapshot-at-the-Beginning)信息到Remembered Set,以跟踪对象引用关系。
  • 最终标记:这是一个短暂的停顿阶段,用于重新处理并发标记阶段结束后遗留下来的少量SATB(Snapshot-at-the-Beginning)记录。它确保了标记过程的完整性。
  • 筛选回收:在这个阶段,G1垃圾回收器根据Region的回收价值和成本进行排序,并制定回收计划,以满足用户期望的停顿时间。然后,它将要回收的一部分Region中的存活对象复制到空的Region中,清理掉整个旧Region的全部空间。这个阶段也是与用户程序并发执行的。

Fully Yong GC

  • STW(Stop The World)
    • 构建CS【Collection Set】(Eden+Survivor)
    • 扫描GC Roots
    • Update RS:排空Dirty Card Queue,并更新Remember Set
    • Process RS:在Remember Set中找到被哪些老年代的对象跨代引用的。
    • Object Copy:常规的对新生代进行标记复制算法(复制到空的 Region 中)
    • Reference Processing:回收可以被回收的引用类型

调优

  • G1记录每个阶段的时间,用于自动调优
  • 记录Eden/Survivor的数量和GC时间
    • 根据暂停目标自动调整Region数量(如果达不到你设定的时间,则减少该Region的数量) 如果你设置了Eden区GC时间只能小于5ms,但是你一次回收了100ms,只能减少Eden区的数量来尽量满足你对该Region的GC最小暂停时间的设置
    • 暂停目标越短,Eden数量越少 如果你设置的Eden区的Region过于短,那么可能会导致Eden区过于少,从而导致CPU大部分时间都在回收Eden区上。导致吞吐量下降(工作线程运行时长/运行总时长)
    • 打印自适应的尺寸调节策略:-XX:+PrintAdaptiveSizePolicy
    • 打印老年代的提升的分布:-XX:+PrintTenuringDistribution

Old GC

  • 当堆用量达到一定程度时除法
    • 三色标记
    • -XX:IntiatingHeapOccupancyPercent=N
    • 45 by default(默认是45)
    • Old GC是并发(concurrent)进行的

ZGC

TODO

JVM调优篇

JVM调优参数

JVM参数设置

参数描述格式说明
-Xms初始化堆空间大小-Xms64M默认值:物理内存的1/64(<1GB)
-Xmx最大堆空间大小-Xmx128M默认值:物理内存的1/4(<1GB)
-Xmn新生代的空间大小-Xmn32M此处的大小是(eden+ 2 survivor space).
与jmap -heap中显示的New gen是不同的

相当于对-XX:newSize、-XX:MaxnewSize
同时设置


整个堆大小=新生代大小 + 老年代大小 +
持久代大小

增大年轻代会较少老年代,可能影响性能,
Sun官方推荐配置为整个堆的3/8
-XX:NewSize新生代初始化内存的大小-XX:NewSize=64M注意:该值需要小于-Xms的值
-XX:MaxNewSize新生代可被分配的内存的最大上限-XX:MaxNewSize=1024M默认:堆最大值的1/3
-XX:MetaspaceSize元空间初始化空间大小-XX:MetaspaceSize=256M默认:物理内存的1/64
-XX:MaxMetaspaceSize元空间可被分配内存的最大上限-XX:MaxMetaspaceSize=1024M最大:物理内存的1/4
-Xss设置线程栈空间大小-Xss512kJDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K,这个值和线程数量成反比
-XX:ThreadStackSize和Xss类似-XX:ThreadStackSize=2M设置线程对堆栈大小
-XX:NewRatio年轻代(包括Eden和两个Survivor
区)与年老代的比值(除去持久代)
-XX:NewRatio=4设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5
-XX:SurvivorRatioEden区与Survivor区的大小比值-XX:SurvivorRatio=8设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10
-XX:MaxTenuringThreshold垃圾最大年龄
SerialParNew有效
-XX:MaxTenuringThreshold=15默认:15

如果设置为0的话,则年轻代对象不经过
Survivor区,直接进入年老代. 对于年老
代比较多的应用,可以提高效率.如果将
此值设置为一个较大值,则年轻代对象
会在Survivor区进行多次复制,这样可
以增加对象在年轻代的存活时间,增加
在年轻代即被回收的概率

该参数只有在串行GC时才有效.
-XX:PretenureSizeThreshold对象超过多大直接在老年代分配-XX:PretenureSizeThreshold=1000000默认值是0,意味着任何对象都会现在新生代分配内存

JVM辅助信息参数设置

参数描述说明
-XX:+PrintGC打印GC信息输出形式:[GC 118250K->113543K(130112K),
0.0094143 secs] [Full GC 121376K->10414K(130112K),
0.0650971 secs]
-XX:+PrintGCDetails打印GC明细输出形式:[GC [DefNew: 8614K->781K(9088K),
0.0123035 secs] 118250K->113543K(130112K), 0.0124633 secs]
[GC [DefNew: 8614K->8614K(9088K), 0.0000665 secs]
[Tenured: 112761K->10414K(121024K), 0.0433488 secs]
121376K->10414K(130112K), 0.0436268 secs]
-XX:PrintHeapAtGC打印GC前后的详细堆栈信息
-verbose:gc输出虚拟机GC详情-XX:+PrintGCDetails 稳定版
-XX:+PrintCommandLineFlags查看当前垃圾回收器

PrintGCDetails 日志详解

JVM参数疑问解答

JVM参数 -Xmn-XX:NewSize / -XX:MaxNewSize,以及 -XX:NewRatio 这三组参数都可以影响年轻代的大小。在混合使用这些参数时,它们的优先级如下:

  • 高优先级:-XX:NewSize / -XX:MaxNewSize:这两个参数具有最高优先级,因为它们直接设置年轻代的初始大小和最大大小。如果同时设置了这两个参数,它们会覆盖其他参数对年轻代大小的影响。

  • 中优先级:-Xmn:如果你设置了 -Xmn 参数,它会以默认等效 -Xmn=-XX:NewSize=-XX:MaxNewSize 的方式影响年轻代的大小。这意味着 -Xmn-XX:NewSize / -XX:MaxNewSize 具有相同的优先级。

  • 低优先级:-XX:NewRatio-XX:NewRatio 参数设置年轻代与老年代的大小比例。它具有较低的优先级,因为它会在已经设置了上述参数的情况下进一步影响年轻代大小。

  • 推荐使用-Xmn参数,原因是这个参数简洁,相当于一次设定 NewSize/MaxNewSIze,而且两者相等,适用于生产环境

调优策略

优化JVM并不是首要选择,而应该优先考虑对应用程序本身的优化。JVM调优通常被视为一种最后的手段,用于解决性能问题或满足特定的需求。在程序优化中,以下是一般的优化顺序:

  1. 程序本身的优化:首先应该关注应用程序的代码和算法。通过改进代码结构、减少不必要的计算、使用更高效的算法等方式,可以显著提高应用程序的性能。
  2. 数据库和存储优化:如果应用程序涉及数据库操作或文件存储,优化数据库查询、索引和数据访问,以及存储方案可以提高性能。
  3. JVM调优:只有在上述步骤都得到了满足,而且性能问题仍然存在,才考虑JVM调优。这可能包括调整内存分配、垃圾回收策略等JVM参数。

选择合适的垃圾回收器

场景垃圾回收器原因
高吞吐量任务,例如后台计算任务Parallel Scavenge + Parallel Old这个组合适用于高吞吐量场景,通过并行回收方式可以减少同一时间段的GC次数,虽然单次GC停顿时间较长,但适合计算密集型应用,能够充分利用多核CPU资源。
用户停顿时间敏感,面向用户应用程序,
JVM内存小于等于4GB,JDK版本小于1.8
ParNew + CMS在内存较小(4GB以内)且要求用户停顿时间敏感的场景下,可以选择ParNew和CMS组合。这两个垃圾回收器都支持多线程并行回收,有助于降低GC停顿时间。CMS使用标记-清除算法,可以降低老年代的回收时间。
用户停顿时间敏感,JDK版本大于等于1.8,
JVM内存大于等于6GB
G1对于要求可控停顿时间、内存较大(6GB以上)的场景,推荐使用G1垃圾回收器。G1回收器可以提供可预测的停顿时间,同时在大内存下具有高回收效率,适合处理复杂的内存管理需求。

调整内存

现象:频繁的垃圾收集操作

原因:垃圾收集频繁的原因可能是堆内存过小,导致需要频繁进行垃圾收集以释放足够的内存来创建新的对象。因此,增加堆内存大小通常会显著减少垃圾收集的频率。

注意:值得注意的是,如果垃圾收集次数非常频繁,但每次垃圾收集只能回收很少的对象,那么问题可能不是堆内存大小太小,而可能是内存泄漏导致的对象无法被回收,从而导致频繁的垃圾收集。

1
2
3
4
5
6
7
8
# 调整初始堆大小
-Xms2g

# 调整最大堆大小
-Xmx4g

# 如果新生代GC频繁,可以考虑分配更多内存给新生代
-Xmn2g

设置符合预期的停顿时间

现象:程序出现间歇性的卡顿现象

原因:如果没有明确设置垃圾收集器的停顿时间目标,垃圾收集器通常以吞吐量为主要目标,这可能导致垃圾收集时间的不稳定性,从而引发程序卡顿。

注意:在设置停顿时间目标时,应避免设置过于不切实际的停顿时间,因为较短的停顿时间可能需要更频繁的垃圾收集操作,从而增加GC的次数,甚至影响性能。

适用垃圾收集器:建议使用Parallel ScavengeG1垃圾收集器来实现可控制的停顿时间。

1
2
# 设置垃圾收集器的最大停顿时间目标,需要进行反复测试以确定合适的值
-XX:MaxGCPauseMillis=200

调整内存区域大小比率

现象:某一个区域的GC频繁,其他区域正常。

原因:可能的原因是对应区域的空间不足,导致需要频繁进行GC以释放空间。在JVM堆内存无法增加的情况下,可以考虑调整对应区域的大小比率。

注意:也有可能并非仅仅是空间不足,而是由于内存泄露导致内存无法被回收,从而引发频繁的GC。

1
2
3
4
5
// 调整新生代和老年代的比例。例如,设置为1表示新生代和老年代的大小相等。默认值是2,意味着老年代是新生代的两倍。
-XX:NewRatio=1

// 调整survivor区和Eden区的大小比率。例如,设置为6表示Eden区的大小是一个Survivor区的6倍。
-XX:SurvivorRatio=6
  1. 扩大新生代的比例:
    • 对象生命周期短:如果应用主要产生的是短生命周期的对象,那么扩大新生代可以减少老年代的GC频率,因为对象会在新生代中被回收。
    • 频繁的Full GC:如果老年代经常满,导致频繁的Full GC,增大新生代可能有助于减少对象到老年代的晋升速度。
    • 频繁的Minor GC:如果新生代经常触发Minor GC,并且每次GC后新生代仍然很满,这可能意味着新生代的空间不足,需要扩大。
    • 老年代使用不足:如果通过监控工具观察到老年代的使用率持续很低,而新生代经常接近满载,这可能是一个信号,表明可以安全地增大新生代的比例。
  2. 缩小新生代的比例:
    • 对象生命周期长:如果应用产生大量的长生命周期对象或大对象,这些对象很快就会晋升到老年代,那么可能需要减少新生代的大小,为老年代分配更多空间。
    • 老年代GC频繁:如果老年代经常触发GC,特别是Full GC,并且每次GC后老年代仍然很满,这可能意味着老年代的空间不足,需要为其分配更多的空间。
  3. 增大Survivor区的比率:
    • 对象晋升过快:如果大量对象在经历了很少的GC周期后就晋升到老年代,可能需要增大Survivor区,以便对象在新生代中停留更长时间。
    • Survivor区溢出:如果Survivor区经常填满,导致对象提前晋升到老年代,增大Survivor区可以帮助缓解这个问题。
    • 应用的对象生命周期:如果应用中有大量的中生命周期对象(即不是很短暂,也不是很长久的对象),增大Survivor区可能有助于提高性能。
  4. 缩小Survivor区的比率:
    • 对象生命周期很短:如果大部分对象都是短暂的,并且很快在Eden区被回收,那么可以缩小Survivor区,为Eden区分配更多空间。
    • Survivor区使用率低:通过JVM监控工具,如果观察到Survivor区的使用率持续很低,这意味着大部分对象要么很快被回收,要么很快晋升,这时可以考虑缩小Survivor区。
    • 频繁的Minor GC但Eden区回收效果不佳:如果Eden区经常触发Minor GC,但每次回收后仍然很满,这可能意味着需要为Eden区分配更多空间,从而缩小Survivor区的比率。

调整对象晋升老年代的年龄

现象:老年代频繁GC,每次回收的对象数量较多。

原因:如果晋升年龄设置得太小,新生代的对象会很快晋升到老年代,导致老年代对象数量增多。而这些对象可能在短时间内就可以被回收。为了解决老年代频繁GC的问题,可以考虑调整对象的晋升年龄,使对象不那么容易晋升到老年代。

注意:增加晋升年龄后,对象在新生代的停留时间会增加,这可能导致新生代GC的频率上升。同时,由于这些对象需要在新生代中经历更多的GC,Minor GC的暂停时间也可能增加。

1
2
// 设置对象晋升到老年代的初始年龄阈值,默认值为15
-XX:InitialTenuringThreshold=20

调整大对象的标准

现象:老年代频繁进行GC,每次回收的对象数量较多,且这些对象的体积都比较大。

原因:大对象如果直接分配到老年代,会导致老年代容易被填满,从而引发频繁的GC。可以通过设置一个阈值,使得超过此阈值的对象直接在老年代分配。

注意:当这些大对象被分配到新生代时,可能会增加新生代的GC频率和暂停时间。

1
2
// 设置对象的大小阈值,超过此值的对象直接在老年代分配。单位是字节,0代表没有限制,全部分配在新生代。
-XX:PretenureSizeThreshold=1000000

-XX:PretenureSizeThreshold 参数只在使用 SerialParNew 垃圾收集器时有效。

调整GC的触发时机

适用于CMSG1

现象CMSG1 经常进行 Full GC,导致程序卡顿严重。

原因:在 G1CMS 的部分GC阶段,垃圾收集是并发进行的,这意味着业务线程和垃圾收集线程会同时运行。因此,业务线程在GC过程中会生成新的对象。为了容纳这些新对象,需要预留一部分内存空间。如果内存空间不足以容纳新产生的对象,JVM会停止并发收集并暂停所有业务线程(STW),以确保垃圾收集可以正常进行。为了避免这种情况,可以提前触发GC,从而预留出足够的空间来容纳业务线程创建的新对象。

注意:提前触发GC可能会增加老年代GC的频率。

1
2
3
4
5
// 当老年代使用率达到此比例时,开始CMS收集。默认值是68%。如果频繁发生Full GC卡顿,应该调小此值。
-XX:CMSInitiatingOccupancyFraction=65

// 在G1混合垃圾回收周期中,设置旧区域的占用率阈值。默认值为65%。
-XX:G1MixedGCLiveThresholdPercent=65

调整 JVM本地内存大小

现象:虽然GC的次数、时间和回收的对象都正常,且堆内存空间充足,但仍然报出OOM错误。

原因:JVM除了堆内存之外,还有一块被称为 本地内存直接内存 的区域。这块内存区域不会主动触发GC,只有在堆内存区域触发GC时才会尝试回收本地内存中的对象。如果本地内存分配不足,将直接抛出OOM异常。

1
-XX:MaxDirectMemorySize=512m

如果不显式设置 -XX:MaxDirectMemorySize,那么默认的直接内存大小是基于Java堆的最大大小。像NIO等其他框架如果有使用直接内存,需要关注此设置。

调优实例

网站流量浏览量暴增后,网站反应页面响很慢

策略

  • 调整JVM内存配置
  • 选择合适的垃圾回收器
  1. 问题推测:尽管在测试环境中响应速度快,但生产环境变慢。可能的原因是垃圾收集导致的业务线程停顿。
  2. 定位:使用jstat -gc指令在线上观察,发现JVM的GC频率很高,且GC所占用的时间也长。这表明GC频繁导致业务线程经常停顿,从而使页面响应变慢。
  3. 解决方案:由于高流量导致对象创建速度快,堆内存容易被填满,从而频繁触发GC。问题可能在于新生代内存设置得太小。为了解决这个问题,我们将JVM的内存从2G增加到6G。
  4. 第二个问题:虽然增加内存后常规请求变快了,但偶尔会出现更长时间的卡顿。
  5. 问题推测:考虑到之前的内存增加,可能是因为单次GC的时间变长导致的间歇性卡顿。
  6. 定位:通过jstat -gc指令再次观察,发现FGC(Full GC)的次数并不多,但每次FGC所花费的时间非常长,有时甚至达到几十秒。
  7. 解决方案:默认的JVM使用的是Parallel Scavenge + Parallel Old组合,这两者在标记和收集阶段都会导致STW(Stop-The-World)。内存增加后,每次GC所需的时间也变长。为了减少单次GC的时间,我们需要切换到并发收集器。考虑到当前的JDK版本是1.7,我们选择CMS收集器(如果是1.8,可以考虑使用G1)。根据之前的GC日志,我们还为CMS设置了一个预期的停顿时间。这样,网站的卡顿问题得到了解决。

后台导出数据引发的OOM

问题描述:公司后台系统偶尔出现OOM异常,导致堆内存溢出。

  1. 初步判断:由于问题是偶发性的,最初的猜测是堆内存不足。因此,我们将堆内存从4G增加到8G。
  2. 问题复现:尽管增加了堆内存,问题仍然存在。为了进一步定位问题,我们启用了-XX:+HeapDumpOnOutOfMemoryError参数,以在OOM时获取堆内存的dump文件。
  3. 堆分析:使用MAT工具对dump文件进行分析。通过MAT的内存泄露报告和Top Consumers功能,我们识别出了大量的大对象。
  4. 代码审查:进一步分析线程和代码,我们注意到一个正在运行的业务线程与“导出订单信息”方法有关。
  5. 问题定位:考虑到订单信息导出可能涉及数万条数据,这个方法首先从数据库查询订单信息,然后将其转换为Excel。这个过程会生成大量的String对象和其他临时数据。
  6. 前端交互问题:为了验证这个猜测,我们尝试在后台进行测试。令人惊讶的是,我们发现导出订单的按钮在前端没有禁用点击交互。因此,如果用户发现点击后页面没有反应,他们可能会连续点击多次。这导致了大量的请求涌入后台,生成了大量的订单和Excel对象。由于这些方法执行很慢,这些对象在短时间内无法被回收,从而导致内存溢出。
  7. 解决方案:了解问题原因后,我们决定不调整JVM参数。而是在前端的“导出订单”按钮上添加了禁用状态,直到后端响应完成后才重新启用。此外,我们还对导出接口进行了限流,以防止用户通过刷新页面连续点击,从而彻底解决了OOM问题。

CPU经常100% 问题定位

问题描述:CPU使用率经常达到100%,可能是由于锁竞争激烈或其他原因导致。

  1. 定位高CPU使用的进程: 使用top命令查看系统中各进程的资源占用情况。

    1
    top
  2. 定位进程中的高CPU使用线程: 根据上一步找到的进程ID,列出该进程中各线程的资源占用情况。

    1
    top -Hp 进程ID
  3. 获取线程的堆栈信息

    • 首先,将线程ID转换为16进制格式:

      1
      printf "%x\n" 线程ID
    • 然后,使用

      1
      jstack

      命令打印出进程的所有线程堆栈信息,并从中找到上一步转换为16进制的线程ID对应的堆栈信息:

      1
      jstack 进程ID
  4. 分析堆栈信息定位问题: 通过分析堆栈信息,查看是否有线程长时间处于WAITINGBLOCKED状态。

    • 如果线程长期处于WAITING状态,关注waiting on xxxxxx部分,这表示线程正在等待某个锁。根据锁的地址,可以找到持有该锁的线程。
    • 如果线程长期处于BLOCKED状态,这通常意味着线程正在尝试获取一个已被其他线程持有的锁。

最后,根据堆栈信息,您可以深入到具体的代码逻辑中,找到可能导致高CPU使用或锁竞争的部分,并进行相应的优化。

数据分析平台系统频繁 Full GC

问题背景:数据分析平台的主要功能是对用户在App中的行为进行定时分析统计,并提供报表导出功能。系统的老年代使用了CMS垃圾回收器。

问题描述:数据分析师在使用平台时发现,系统页面打开时经常出现卡顿。通过使用jstat命令监控,发现每次Young GC后,大约有10%的存活对象被晋升到老年代。

问题原因:经过分析,确定问题的原因是Survivor区域设置得过小。每次Young GC后,由于存活对象在Survivor区域放不下,这导致它们提前被晋升到老年代。

解决方案:为了解决这个问题,我们调整了Survivor区的大小,确保它可以容纳Young GC后的存活对象。这样,对象会在Survivor区经历多次Young GC,只有当它们达到一定的年龄阈值时才会被晋升到老年代。

效果:调整后,每次Young GC后进入老年代的存活对象数量大大减少,稳定运行时仅有几百Kb。这大大降低了Full GC的频率,从而提高了系统的响应速度。

MQ消费者 OOM

问题背景:系统主要功能是消费Kafka数据,进行数据处理和计算,然后将处理后的数据转发到另一个Kafka队列。

问题描述:系统在运行几小时后出现OOM(内存溢出)异常。即使重启系统,几小时后仍然会再次出现OOM。

问题定位:为了定位问题,我们使用jmap工具导出了系统的堆内存快照。然后,我们使用eclipse MAT工具对这个快照进行了分析。

问题原因:分析结果显示,大量的对象在内存中堆积,等待被同步打印到日志。进一步的代码审查发现,我们在代码中异步打印了某个业务Kafka的topic数据。由于这个业务的数据量非常大,这导致了大量的对象在内存中堆积,最终导致了OOM。

解决方案:为了解决这个问题,使用日志框架的异步打印方式,并设置异步队列的大小,确保不会有大量的对象在内存中堆积。此外,我们还增加了适当的流控制和内存监控,以确保系统在面对大量数据时仍能稳定运行。

JVM监控工具

jps

jps (Java Virtual Machine Process Status Tool) 是 Java JDK 中的一个实用工具,用于列出正在运行的 JVM 进程。它可以帮助你快速查找 Java 进程的进程ID。

  • -l: 输出完整的包名和应用主类。
  • -m: 输出传递给 JVM 的参数。
  • -v: 输出传递给 JVM 的 JVM 参数。
  • -q: 只输出进程ID,不输出类名、jar名或参数。

jsp

查看启动类名和进程id

jps –l

输出主类或者jar的完全路径名

jps –v

输出jvm参数

jstat

jstat 是一个用于监控Java HotSpot VM性能的命令行工具。它可以提供有关类加载、即时编译和垃圾收集的统计信息。

使用方法

1
jstat [option] [vmid] [interval] [count]

其中:

  • option 是你想要的统计信息的类型。
  • vmid 是虚拟机的进程ID。
  • intervalcount 是可选的,用于指定多久收集一次数据和总共收集多少次数据。

常用的选项:

  • -class: 显示类加载器的行为统计。
  • -compiler: 显示即时编译的统计。
  • -gc: 显示垃圾收集的统计。
  • -gccapacity: 显示各个代的当前和最大容量。
  • -gcutil: 显示各个代的使用百分比。
  • -gcnew: 显示新生代的统计。
  • -gcnewcapacity: 显示新生代的容量。
  • -gcold: 显示老年代的统计。
  • -gcoldcapacity: 显示老年代的容量。

jstat -gc pid

1
2
如下表示分析进程id为31736gc情况,每隔1000ms打印一次记录,打印10次停止,每3行后打印指标头部
jstat -gc -h3 31736 1000 10

列名描述
S0C当前 Survivor 0 区的容量 (KB)
S1C当前 Survivor 1 区的容量 (KB)
S0U当前 Survivor 0 区已使用的空间 (KB)
S1U当前 Survivor 1 区已使用的空间 (KB)
EC当前 Eden 区的容量 (KB)
EU当前 Eden 区已使用的空间 (KB)
OC当前老年代的容量 (KB)
OU当前老年代已使用的空间 (KB)
MC元空间的容量 (KB) (Java 8及以后版本)
MU元空间已使用的空间 (KB) (Java 8及以后版本)
CCSC压缩类空间的容量 (KB) (仅在使用压缩Oops时)
CCSU压缩类空间已使用的空间 (KB) (仅在使用压缩Oops时)
YGC从应用启动到当前时刻发生的次要垃圾收集事件的次数
YGCT从应用启动到当前时刻次要垃圾收集所花费的总时间
FGC从应用启动到当前时刻发生的完全垃圾收集事件的次数
FGCT从应用启动到当前时刻完全垃圾收集所花费的总时间
GCT从应用启动到当前时刻垃圾收集所花费的总时间 (YGCT + FGCT)

jstat -gcutil pid

显示各个代的使用百分比

参数说明
S0幸存1区当前使用比例
S1幸存2区当前使用比例
E伊甸园区使用比例
O老年代使用比例
M元数据区使用比例
CCS压缩使用比例
YGC年轻代垃圾回收次数
YGCT年轻代垃圾回收消耗时间
FGC老年代垃圾回收次数
FGCT老年代垃圾回收消耗时间
GCT垃圾回收消耗总时间

jstat -gcnew pid

新生代垃圾回收统计

参数说明
S0C第一个幸存区大小
S1C第二个幸存区的大小
S0U第一个幸存区的使用大小
S1U第二个幸存区的使用大小
TT对象在新生代存活的次数
MTT对象在新生代存活的最大次数
DSS期望的幸存区大小
EC伊甸园区的大小
EU伊甸园区的使用大小
YGC年轻代垃圾回收次数
YGCT年轻代垃圾回收消耗时间

jstat -gcold pid

老年代垃圾回收统计

参数说明
MC方法区大小
MU方法区使用大小
CCSC压缩类空间大小
CCSU压缩类空间使用大小
OC老年代大小
OU老年代使用大小
YGC年轻代垃圾回收次数
FGC老年代垃圾回收次数
FGCT老年代垃圾回收消耗时间
GCT垃圾回收消耗总时间

jstat -gccapacity pid

堆内存统计

参数说明
NGCMN新生代最小容量
NGCMX新生代最大容量
NGC当前新生代容量
S0C第一个幸存区大小
S1C第二个幸存区的大小
EC伊甸园区的大小
OGCMN老年代最小容量
OGCMX老年代最大容量
OGC当前老年代大小
OC当前老年代大小
MCMN最小元数据容量
MCMX最大元数据容量
MC当前元数据空间大小
CCSMN最小压缩类空间大小
CCSMX最大压缩类空间大小
CCSC当前压缩类空间大小
YGC年轻代GC次数
FGC老年代GC次数

jinfo

jinfo 用于获取 Java 进程的配置信息和 JVM 参数。它可以显示和调整运行时的 JVM 参数

jinfo pid

jstack

线程状态快照 死锁分析

jstack(Java Virtual Machine Stack Trace)用于生成 Java 线程的堆栈跟踪。这个工具对于诊断性能问题、锁竞争、死锁和其他线程相关的问题非常有用。

选项作用
-F当正常输出的请求不被响应时,强制输出线程堆栈
-m如果调用到本地方法的话,可以显示C/C++的堆栈
-l除堆栈外,显示关于锁的附加信息,在发生死锁时可以用jstack -l pid来观察锁持有情况

线程状态

  • New:创建后尚未启动的线程处于这种状态,不会出现在Dump中。

  • RUNNABLE:包括Running和Ready。线程开启start()方法,会进入该状态,在虚拟机内执行的。

  • Waiting:无限的等待另一个线程的特定操作。

  • Timed Waiting:有时限的等待另一个线程的特定操作。

  • 阻塞(Blocked):在程序等待进入同步区域的时候,线程将进入这种状态,在等待监视器锁。

  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。

死锁排查

死锁代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class LockTest {
private static Object lock1 = new Object();
private static Object lock2 = new Object();

private static void f1() {
synchronized (lock1) {
synchronized (lock2) {
System.out.println("线程" + Thread.currentThread().getName() + "获得两把锁");
}
}
}

private static void f2() {
synchronized (lock2) {
synchronized (lock1) {
System.out.println("线程" + Thread.currentThread().getName() + "获得两把锁");
}
}
}

public static void main(String[] args) {

new Thread(() -> {
while (true) {
f1();
}
}).start();

new Thread(() -> {
while (true) {
f2();
}
}).start();
}
}

运行命令: jstack -l 244

CPU过载分析

参考 调优实例 CPU经常100% 问题定位

jmap

java9后改用 jhsdb

用于生成 Java 堆转储 (heap dump) 和查询堆内存的详细信息。这个工具对于诊断内存泄漏、分析对象的内存占用和其他与内存相关的问题非常有用。

jmap [option]

  • heap:打印Java堆概要信息,包括使用的GC算法、堆配置参数和各代中堆内存使用情况;

  • histo[:live]: 打印Java堆中对象直方图,通过该图可以获取每个class的对象数目,占用内存大小和类全名信息,带上:live,则只统计活着的对象;

  • finalizerinfo: 打印等待回收的对象信息

  • dump:以hprof二进制格式将Java堆信息输出到文件内,该文件可以用MAT、VisualVM或jhat等工具查看;

jmap -heap pid

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
> jmap -heap 10352

jmap -heap 10352
Attaching to process ID 10352, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.201-b09

using thread-local object allocation.
Parallel GC with 4 thread(s)

Heap Configuration:
//对应jvm启动参数-XX:MinHeapFreeRatio设置JVM堆最小空闲比率(defalut 40)
MinHeapFreeRatio = 0
//对应jvm启动参数 -XX:MaxHeapFreeRatio设置JVM堆最大空闲比率(default 70)
MaxHeapFreeRatio = 100
//对应jvm启动参数-XX:MaxHeapSize=设置JVM堆的最大大小
MaxHeapSize = 4280287232 (4082.0MB)
//对应jvm启动参数-XX:NewSize=设置JVM堆的‘新生代’的默认大小
NewSize = 89128960 (85.0MB)
//对应jvm启动参数-XX:MaxNewSize=设置JVM堆的‘新生代’的最大大小
MaxNewSize = 1426587648 (1360.5MB)
//对应jvm启动参数-XX:OldSize=<value>:设置JVM堆的‘老年代’的大小
OldSize = 179306496 (171.0MB)
//对应jvm启动参数-XX:NewRatio=:‘新生代’和‘老生代’的大小比率
NewRatio = 2
//对应jvm启动参数-XX:SurvivorRatio=设置年轻代中Eden区与Survivor区的大小比值
SurvivorRatio = 8
//对应jvm启动参数-XX:MetaspaceSize=<value>:设置JVM堆的‘元空间’的初始大小
// jdk1.8 永久代已经被元空间所取代
MetaspaceSize = 21807104 (20.796875MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
//对应jvm启动参数-XX:MaxMetaspaceSize= :设置JVM堆的‘元空间’的最大大小
MaxMetaspaceSize = 17592186044415 MB
G1HeapRegionSize = 0 (0.0MB)
//堆内存分布
Heap Usage:
//新生代的内存分布
PS Young Generation
//Eden区内存分布
Eden Space:
//Eden区总容量
capacity = 1425539072 (1359.5MB)
//Eden区已使用
used = 28510792 (27.19001007080078MB)
//Eden区剩余容量
free = 1397028280 (1332.3099899291992MB)
//Eden区使用比率
2.0000007407724003% used
From Space:
capacity = 524288 (0.5MB)
used = 65536 (0.0625MB)
free = 458752 (0.4375MB)
12.5% used
To Space:
capacity = 524288 (0.5MB)
used = 0 (0.0MB)
free = 524288 (0.5MB)
0.0% used
PS Old Generation
capacity = 128974848 (123.0MB)
used = 24006808 (22.894676208496094MB)
free = 104968040 (100.1053237915039MB)
18.613557893086256% used

13410 interned Strings occupying 1194568 bytes.

jmap -histo pid

展示class的内存情况,类似MAT的直方图

jmap -dump:format=b,file=D:\dump\dump.hprof 11404

导出dump文件到指定文件夹

jhat

jhat (Java Heap Analysis Tool) 用于分析 Java 堆转储文件。它可以帮助你诊断内存泄漏、分析对象的内存占用和其他与内存相关的问题。

jhat 的一个特点是它可以启动一个 Web 服务器,允许你通过 Web 浏览器浏览堆转储的内容。这为分析大型堆转储提供了一个方便的界面。

jhat [ options ] heap-dump-file

jconsole

Jconsole是JDK自带的监控工具,在JDK/bin目录下可以找到。它用于连接正在运行的本地或者远程的JVM,对运行在java应用程序的资源消耗和性能进行监控,并画出大量的图表,提供强大的可视化界面。而且本身占用的服务器内存很小,甚至可以说几乎不消耗。

命令行下数据启动命令 jconsole

jvisualvm

下载

VisualVM 是一款免费的,集成了多个 JDK 命令行工具的可视化工具,它能为您提供强大的分析能力,对 Java 应用程序做性能分析和调优。这些功能包括生成和分析海量数据、跟踪内存泄漏、监控垃圾回收器、执行内存和 CPU 分析,同时它还支持在 MBeans 上进行浏览和操作

命令行下数据启动命令 jvisualvm

下载额外插件

MAT 内存数据分析

MAT中文文档

Arthas 诊断工具

使用参考

Arthas 阿尔萨斯 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率

jvm 相关

  • dashboard - 当前系统的实时数据面板
  • getstatic - 查看类的静态属性
  • heapdump - dump java heap, 类似 jmap 命令的 heap dump 功能
  • jvm - 查看当前 JVM 的信息
  • logger - 查看和修改 logger
  • mbean - 查看 Mbean 的信息
  • memory - 查看 JVM 的内存信息
  • ognl - 执行 ognl 表达式
  • perfcounter - 查看当前 JVM 的 Perf Counter 信息
  • sysenv - 查看 JVM 的环境变量
  • sysprop - 查看和修改 JVM 的系统属性
  • thread - 查看当前 JVM 的线程堆栈信息
  • vmoption - 查看和修改 JVM 里诊断相关的 option
  • vmtool - 从 jvm 里查询对象,执行 forceGc

class/classloader 相关

  • classloader - 查看 classloader 的继承树,urls,类加载信息,使用 classloader 去 getResource
  • dump - dump 已加载类的 byte code 到特定目录
  • jad - 反编译指定已加载类的源码
  • mc - 内存编译器,内存编译.java文件为.class文件
  • redefine - 加载外部的.class文件,redefine 到 JVM 里
  • retransform - 加载外部的.class文件,retransform 到 JVM 里
  • sc - 查看 JVM 已加载的类信息
  • sm - 查看已加载类的方法信息

monitor/watch/trace 相关

注意

请注意,这些命令,都通过字节码增强技术来实现的,会在指定类的方法中插入一些切面来实现数据统计和观测,因此在线上、预发使用时,请尽量明确需要观测的类、方法以及条件,诊断结束要执行 stop 或将增强过的类执行 reset 命令。

  • monitor - 方法执行监控
  • stack - 输出当前方法被调用的调用路径
  • trace - 方法内部调用路径,并输出方法路径上的每个节点上耗时
  • tt - 方法执行数据的时空隧道,记录下指定方法每次调用的入参和返回信息,并能对这些不同的时间下调用进行观测
  • watch - 方法执行数据观测

内存溢出自动dump

-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/oom

指定最大堆内存大小,当发生OOM异常时,将堆内存数据dump到指定的文件夹下

JMM 内存模型

参考 InfoQ Simon郎 文章

String问题

String str = “R”; 一共创建了几个对象

思路分析:"R"是一个字面量,会放在字符串常量池子中

两种情况

  • 如果字符串常量池中已经存在“R“,那么创建0个对象
  • 如果字符串常量池不存在“R“,那么在常量池中创建1个对象

new String(“R”); 创建了几个对象?

思路分析: new 关键字一定会在内存中创建一个对象,还要把字面量“R“提取出来分析


两种情况

  • 如果字符串常量池中已经存在“R“,那么创建1个对象(堆中1个)
  • 如果字符串常量池不存在“R“,那么在常量池中创建2个对象(字符串常量池1个,堆中1个)
1
2
3
String str = "Java";
String str2 = “Java“;
为什么 str == str2 为true

思路分析:java对于常量(字面量)会存储在字符串常量池中,str1第一次赋值时在常量池中添加了“Java“并返回其引用, str2第二次赋值直接

引用字符串常量池。两个都是常量池 “Java” 的引用

1
2
3
String str = "Java";
String str2 = "Ja" + "va";
为什么 str == str2 为true

思路分析:参考上面,常量拼接还是常量(编译期优化)

1
2
3
String str = "Java";
String str2 = new String("Java");
为什么 str == str2 为false

思路分析: String str = “Java”; 内存存储区域在字符串常量池,new String(“Java”);内存存储区域在堆

1
2
3
4
String str1 = "java";
String str2 = "ja";
String str3 = str2 + "va";
为什么 str1 == str3 为false

思路分析:因为两者属于不同对象,第一个对象在常量池中,第二个对象在堆中

str2 + “va”; 字符串拼接底层使用的是 StringBuilder#append,最终使用StringBuilder#toString方法返回String对象

1
2
3
4
public String toString() {
// 创建一个新的String对象
return new String(value, 0, count);
}

String.inertn()

String.intern()方法设计的初衷就是:重用字符串对象,以便节省内存

JDK1.8, 先判断常量池中当前字符串是否存在

  • 如果不存在:不会将当前字符串复制到常量池,而是将当前字符串的引用复制到常量池
  • 如果存在:不会改变常量池已经存在的引用,并直接返回常量池中字符串引用
1
2
3
Strign str1 = "java";
String str2 = new String("Ja") + new String("va");
str1 == str2.intern(); //false

思路分析:因为“java”已经创建并存在str1,所以str2.intern()返回的是常量池已存在的字符串str1的引用,两者并非同一字符串

1
2
Strign str1 = new String("ja") + new String("va");
str1 == str2.intern(); // true

思路分析:因为常量池不纯在时,不会将当前字符串复制到常量池,而是将当前字符串的引用复制到常量池,两者是同一对象

java存在的引用

引用类型回收时间用途
强引用永不回收普通对象引用
软引用在内存不足回收缓存对象
弱引用垃圾回收时缓存对象
虚引用不确定不确定

强引用

强引用:强应用就是我们平时对象的引用,JVM不会回收带有强引用的对象,即使内存不足导致OOM

1
User user = new User();

我们平时使用变量对对象的引用就是强引用,user持有这个对象的存储地址引用

软引用

软引用:如果一个对象只有软引用

  • 在内存空间足够的情况下,垃圾回收器就不会回收它
  • 如果内存空间不够了,就会对这些只有软引用的对象进行回收
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 设置堆内存 -Xmx20M
public static void main(String[] args) {
SoftReference<byte[]> softReference = new SoftReference<>(new byte[1024 * 1024 * 10]);
System.out.println(softReference.get());
System.gc();
System.out.println(softReference.get());
byte[] bytes = new byte[1024 * 1024 * 10];
System.out.println(softReference.get());
}

输出
[B@6956de9
[B@6956de9
null

弱引用

弱引用:在垃圾回收器扫描内存区域时,一旦发现了只具有弱引用的对象,不管当前内存空间是否足够,都会进行回收

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
WeakReference<String> weakReference = new WeakReference<>(new String("java"));
System.out.println(weakReference.get());
System.gc();
System.out.println(weakReference.get());
System.out.println("-------------");

// 错误用法,因为这里的java会被放在常量池属于强引用
WeakReference<String> weakReference1 = new WeakReference<>("java");
System.out.println(weakReference1.get());
System.gc();
System.gc();
System.gc();
System.out.println(weakReference1.get());
}
1
2
3
4
5
6
7
8
9
10
11
12
 public static void main(String[] args) {
// 使用WeakHashMap 作为缓存
WeakHashMap<String, Object> cache = new WeakHashMap<>();
// key不能使用常量
cache.put(new String("java"), "java");
System.out.println(cache);
System.gc();;
System.out.println(cache);
}
输出
{java=java}
{}

当WeakHashMap没有被正确使用就可能造成OOM,退化成hashMap

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
// 使用WeakHashMap 作为缓存
WeakHashMap<Object, Object> cache = new WeakHashMap<>();
// test会被放入常量池,变成强引用
cache.put("test", "test");
for (int i = 0; i < 10; i++) {
System.gc();
}
System.out.println(cache);
}

虚引用

虚引用:顾名思义就是形同虚设,虚引用不决定对象的生命周期,如果一个对象仅持有虚引用那么它就和没有任何引用一样在任何时候都能被垃圾回收器回收

1
2
3
4
5
public static void main(String[] args) {
// 需要配合引用队列使用
ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();
PhantomReference<String> phantomReference = new PhantomReference<>(new String("java"), referenceQueue);
}

jvm
https://wugengfeng.cn/2022/03/29/jvm/
作者
wugengfeng
发布于
2022年3月29日
许可协议