学习视频来源:https://www.bilibili.com/video/BV1yE411Z7AP
Java类加载机制与ClassLoader详解推荐文章:https://yichun.blog.csdn.net/article/details/102983363
学习笔记——JVM
前言
学习视频地址
一、什么是JVM?
定义:Java Virtual Machine ——Java虚拟机(Java程序的运行环境)
优点:
- 一次编译,到处运行
- 自动内存管理,垃圾回收功能
- 数组下标越界检查
- 多态
比较JDK、JRE、JVM:
二、JVM内存结构
1. 程序计数器(Program Counter Register)
程序计数器(寄存器,Program Counter Register)
作用:记录下一条JVM指令的执行地址
特点:1. 线程私有;2. 不会发生内存溢出
下图中,左侧为二进制字节码,JVM指令;右侧为Java源代码
2. 栈(Stack)
Java虚拟机栈(Java Virtual Machine Stacks)
每个线程运行时所需要的内存对应着Java虚拟机栈,每个栈由多个栈帧(Frame)组成,对应着每个方法调用时所占用的内存,每个栈内只能有一个活动的栈帧,对应着当前正在执行的方法。它是线程私有的。
Q&A
- 垃圾回收是否涉及栈内存?
栈帧内存在栈帧出栈时会被释放,不需要垃圾回收;垃圾回收只负责回收堆中对象。 - 栈内存分配越大越好吗?
栈内存分配的越大,递归调用次数可以越多,但可能会影响同时运行的线程数。假如有500MB内存,每个栈内存分配1MB,此时理想可运行线程数为500个;每个栈内存2MB,此时理想可运行线程数为250个。一般Linux系统默认栈内存为1024KB。 - 方法内的局部变量是否线程安全?
局部变量属于每个线程私有,而非共享,如果该变量未逃离方法的作用范围就不存在线程安全问题;反之,则可能因变量共享而存在线程安全问题。 - 栈内存溢出(java.lang.StackOverflowError)
产生原因:1. 栈帧过多;2. 栈帧过大
本地方法栈(Native Method Stacks)
本地方法栈实现类似于Java虚拟机栈,但其服务于native方法。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。它也是线程私有的。
Q&A
-
如何定位CPU占用过高的线程与代码?
先使用top
命令定位哪个进程占用CPU过高,再使用ps H -eo pid,tid,%cpu | grep $pid
命令定位具体是哪个线程占用CPU过高,最后将该线程的tid转换为十六进制即得到其nid,使用jstack $pid
命令找到该线程并定位具体执行的类及代码行。至此可知具体是哪里的代码存在问题 -
程序运行很长时间没有结果
疑似发生死锁导致该问题,可使用jstack $pid
命令分析(或jconsole
)
3. 堆(Heap)
堆(Heap)
通过new关键字创建的对象都会在堆中,使用堆内存
特点:1. 线程共享;2. 存在垃圾回收机制
堆内存溢出(java.lang.OutOfMemoryError: Java Heap Space)
堆中已使用对象占用内存的总数大于堆最大内存限制
堆内存诊断工具及命令:
- jps工具
查看当前系统中有哪些Java进程。jps
- jmap工具
查看堆内存该时刻占用情况。jmap -heap $pid
- jconsole工具
动态监测堆内存占用情况的多功能图形界面工具。jconsole
- jvirsualvm工具
动态监测堆内存占用情况的超多功能图形界面工具。jvirsualvm
可使用如下代码案例作为监测对象:
public static void main(String[] args) throws InterruptedException {
System.out.println("step 1");
Thread.sleep(30000);
byte[] megaArray = new byte[10 * 1024 * 1024]; // 10MB
System.out.println("step 2");
Thread.sleep(20000);
array = null;
System.gc();
System.out.println("step 3");
Thread.sleep(1000000L);
}
4. 方法区(Method Area)
方法区(Method Area)
方法区存储与类结构相关的信息,类成员变量、方法数据、成员方法、构造器方法代码部分。虽然其在逻辑上属于堆的一部分,但各个JVM的实现并不一定如此。方法区是线程共享的(方法区是永久代与元空间的规范,永久代是Hotspot在1.8之前的实现,元空间属于1.8及之后的实现)
JDK1.6、1.7、1.8 JMM区别
JDK 1.6:程序计数器、Java虚拟机栈、本地方法栈、堆、方法区[永久代](字符串常量池、静态变量、运行时常量池、类常量池)
JDK 1.7:程序计数器、Java虚拟机栈、本地方法栈、堆(字符串常量、静态变量)、方法区[永久代](运行时常量池、类常量池)
JDK 1.8:程序计数器、Java虚拟机栈、本地方法栈、堆(字符串常量)、元数据(静态变量、运行时常量池、类常量池)
方法区内存溢出
-
1.8之前存在永久代内存溢出:java.lang.OutOfMemoryError: PermGen Space
-XX:MaxPermSize=8m -
1.8及其之后存在元空间内存溢出:java.lang.OutOfMemoryError: Metaspace
-XX:MaxMetaspaceSize=8m
代码示例:
/**
* 由于方法区在1.8后的元空间实现使用的是系统内存,
* 而我的PC使用的是16G内存,很难触发该问题,所以需添加如下参数,以指定最大元空间内存
* -XX:MaxMetaspaceSize=8m(如果是1.8之前,则需指定-XX:MaxPermSize=8m)
*/
public class Demo extends Classloader {
public static void main(String[] args) {
int n = 0;
try {
Demo demo = new Demo();
for (int i = 0; i < 10000; i ++, n ++) {
// ClassWriter可用作生成类的二进制字节码
ClassWriter cw = new ClassWriter(0);
// 版本号,访问权限,类名,包名,父类,接口
cw.visit(Opcodes.V1_8, Opcodes.ACC_PLUBLIC, "Class" + i, null, "java/lang/Object", null);
// 返回byte数组
byte[] code = cw.toByteArray();
// 执行加载类
demo.defineClass("Class" + i, code, 0, code.length);
}
} finally {
System.out.println(n);
}
}
}
5. 其他
5.1 常量池
常量池可以理解为一张表,虚拟机指令根据这张表找到要执行的类名、方法名、参数类型、字面量等信息
5.2 运行时常量池
当类被加载,它的常量池信息就会放入运行时常量池,并将其中的符号地址变为真实地址
Java程序:
public class StringTableTest {
public static void main(String[] args) {
String a = "a";
String b = "b";
String ab = "ab";
}
}
反编译成字节码文件:
ziang.zhang@ziangzhangdeMacBook-Pro test % javap -v StringTableTest.class
Classfile /Users/ziang.zhang/dreamPointer/JavaSpace/JavaProjects/data-structures-and-algorithms/out/production/leetcode/test/StringTableTest.class
Last modified 2022-10-3; size 496 bytes
MD5 checksum 92426c26340f906a2a36f096f6c1fd33
Compiled from "StringTableTest.java"
public class test.StringTableTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#24 // java/lang/Object."<init>":()V
#2 = String #18 // a
#3 = String #20 // b
#4 = String #21 // ab
#5 = Class #25 // test/StringTableTest
#6 = Class #26 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Ltest/StringTableTest;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 a
#19 = Utf8 Ljava/lang/String;
#20 = Utf8 b
#21 = Utf8 ab
#22 = Utf8 SourceFile
#23 = Utf8 StringTableTest.java
#24 = NameAndType #7:#8 // "<init>":()V
#25 = Utf8 test/StringTableTest
#26 = Utf8 java/lang/Object
{
public test.StringTableTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ltest/StringTableTest;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=4, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: return
LineNumberTable:
line 6: 0
line 7: 3
line 8: 6
line 10: 9
LocalVariableTable:
Start Length Slot Name Signature
0 10 0 args [Ljava/lang/String;
3 7 1 a Ljava/lang/String;
6 4 2 b Ljava/lang/String;
9 1 3 ab Ljava/lang/String;
}
SourceFile: "StringTableTest.java"
在程序运行时,常量池中的信息都会被加载到运行时常量池,此时a、b、ab作为符号存在于常量池中。字节码指令ldc # 2
检查字符串常量池中是否含有该符号的字符串对象,有则拿来使用,没有则创建字符串对象并放入。可见字符串的创建是懒惰的
5.3 字符串常量池(StringTable)
特性
- 常量池中的字符串仅是符号,第一次使用时才变为对象
- 利用字符串常量池机制,避免重复创建字符串对象
- 字符串变量拼接是利用StringBuilder(1.8)
- 字符串常量拼接是根据编译期的优化
- 可以使用intern()方法,主动将字符串常量池中还没有的字符串对象放入其中
1.8:如果没有则放入,有则不放入,返回值为池中对象
1.6:如果没有则复制一份放入,有则不放入,返回值为池中对象
位置
JDK1.6:位于常量池中,而常量池位于方法区(永久代)
JDK1.8:位于堆中
StringTable 垃圾回收
案例:
// 运行前添加JVM参数:-Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) {
int i = 0;
try {
for (int j = 0; j < 10000; j ++) {
String.valueOf(j).intern();
i ++;
}
} catch (Exeception e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
StringTable 性能调优
- 调整 -XX:StringTableSize=桶个数
StringTable实质是一个哈希表,当桶的个数越多时,哈希碰撞的几率越小,查找速度越快。可以使用此参数调整该表中桶的个数。 - 考虑将字符串是否入池
当重复字符串较多时,适当的使用intern()可以避免重复的字符串对象被创建,从而减少内存占用。
Q&A
- 判断如下代码运行结果
false, truepublic static void main(String[] args) { String s1 = "a"; String s2 = "b"; String s3 = "ab"; String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString(); String s5 = "a" + "b"; // javac在编译期的优化,在编译期就可以确定其值为"ab" System.out.println(s3 == s4 + ", " + s3 == s5); }
因为字符串s4的创建是以StringBuilder作为底层原理;字符串s5的创建是根据编译期的优化,由于字符串对象"ab"在创建s3时已经被放置在字符串常量池中,s5引用的对象也是位于字符串常量池中的"ab"。 - 判断如下代码运行结果
false, false, truepublic static void main(String[] args) { // 动态拼接的字符串仅存在于堆中,字符串常量池中并不存在 String s1 = new String("a") + new String("b"); String s2 = "ab"; // 尝试将此字符串对象放入字符串常量池,若已存在则不放入,不存在则放入。返回池中的对象 String s3 = s1.intern(); System.out.println(s1 == "ab" + ", " + s1 == s3 + ", " + s2 == s3); }
因为s1是由对象动态拼接,最终生成的“ab”存在于堆中,而字符串常量池中并不存在;s2是由运行时创建的字符串对象,存在于池中。s3取自字符串常量池中,与s2对象相同
5.4 直接内存(DirectMemory)
BIO:Java程序本身并不会直接读取磁盘中的内容,而是借助操作系统的API来执行。此时CPU由用户态切换为内核态,内存将开辟一块系统缓冲区,以此来缓冲读取磁盘文件,Java程序借助byte数组再次创建缓冲区,从而在系统缓冲区中读取数据。
NIO:开辟一条Java堆内存与系统内存互通的直接内存,可以在该直接内存中直接读取磁盘文件中的数据,从而大大提升效率。
特点:
- 常见于NIO操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受JVM内存回收管理,其释放借用了虚引用机制
分配和回收原理
- 使用Unsafe对象完成直接内存的分配和回收,回收时需要主动调用Unsafe类中freeMemory方法
- ByteBuffer的实现类使用Cleaner(虚引用)监测ByteBuffer对象,一旦ByteBuffer对象被垃圾回收,那么就会由ReferenceHandler线程通过Cleaner的clean方法调用freeMemory释放直接内存
内存溢出时报错OutOfMemoryError: Direct buffer memory
三、垃圾回收
1. 如何判断对象可以被回收
1.1 引用计数法
引用计数法是为对象添加一个引用计数器,然后用一块额外的内存区域来存储每个对象被引用的次数,当对象每有一个地方引用它时,那我们对该对象的引用计数就会加1,反之每有一个引用失效时,我们对该对象的引用计数就会减1, 当对象的被引用次数为0时,那么我们可以认为这个对象是不会被再次使用了,通过这种方式我们能快速直观的定位到这些可回收的对象,从而进行清理。
引用计数法不完美的地方
-
无法解决循环引用的问题
引用计数法虽然很直观高效,但是通过引用计数法是没办法扫描到一种特殊情况下的“可回收”对象,这种特殊情况就是对象循环引用的时候,比如A对象引用了B,B对象引用了A,除此之外他们两个没有被任何其他对象引用,那么其实这部分对象也属于“可回收”的对象,但是通过引用计数法是没办法定位的。
-
另外一个方面是引用计数法需要额外的空间记录每个对象的被引用的次数,这个引用数也需要去额外的维护。
1.2 可达性分析
可达性分析法是通过以所有的“GC Roots”对象为出发点,如果无法通过GC Roots的引用追踪到的对象,那我们认为这些对象就不会再次被使用了,现在主流的程序语言都是通过可达性分析法来判断对象是否存活的。
哪些对象对象我们称之为"GC Roots"对象呢? 当然普通的对象肯定是不行的,如果要作为GC Roots 对象那么它自身肯定得满足一个条件,那就是他自己一定在很长一段时间内都不会被GC 回收掉。那么只有满足这个条件的对象才可能作为GC Roots了,GC Roots的类型大致如下:
-
虚拟机栈中的本地变量所引用的对象。
-
方法区中静态属性引用的对象。
-
方法区中常量引用的对象。
-
本地方法中(Native方法)引用的对象。
-
虚拟机内部的引用对象(类记载器、基本数据对应的Class对象,异常对象)。
-
所有被同步锁(Synchronnized)持有的对象。
-
描述虚拟机内部情况的对象(如 JMXBean、JVMTI中注册的回调、本地缓存代码)。
-
垃圾搜集器所引用的对象
1.3 四种引用
- 强引用
- 只有GC Roots对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
- 软引用(SoftReference)
- 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用对象
- 可以配合引用队列来释放软引用自身
- 弱引用(WeakReference)
- 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
- 可以配合引用队列来释放弱引用自身
- 虚引用(PhantomReference)
- 必须配合引用队列使用,主要配合ByteBuffer使用,被引用对象回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放直接内存
- 终结器引用(FinalReference)
- 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由Finalizer线程通过引用终结器引用找到被引用对象并调用它的finalize方法,第二次GC时才能回收被引用对象
软引用示例
/**
* 演示软引用,配合引用队列
* -Xmx20M -XX:+PrintGCDetails -verbose:gc
*/
public static void main(String[] args) {
List<SoftReference<byte[]>> list = new ArrayList<>();
// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
for (int i = 0; i < 5; i ++) {
// 关联引用队列:当软引用所引用的byte[]被回收后,该软引用会自动进入queue
SoftReference<byte[]> softRef = new SoftReference<>(new byte[4 * 1024 * 1024], queue);
System.out.println(softRef.get());
list.add(softRef);
}
// 在queue中poll出无用的软引用对象,并在list中remove掉
Reference<? extends byte[]> poll = queue.poll();
while (poll != null) {
list.remove(poll);
poll = queue.poll();
}
for (SoftReference<byte[]> softRef : list) {
System.out.println(softRef.get());
}
}
弱引用示例
/**
* 演示弱引用
* -Xmx20M -XX:+PrintGCDetails -verbose:gc
*/
public static void main(String[] args) {
List<WeakReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 5; i ++) {
WeakReference<byte[]> weakRef = new WeakReference<>(new byte[4 * 1024 * 1024]);
list.add(weakRef);
// 输出每次add后的list内容
for (WeakReference<byte[]> one : list) {
System.out.println(one.get() + " ");
}
System.out.println();
}
System.out.println("running over, list size: " + list.size());
}
输出结果:
[B@7ea987ac
[B@7ea987ac [B@12a3a380
[B@7ea987ac [B@12a3a380 [B@29453f44
[B@7ea987ac [B@12a3a380 [B@29453f44 [B@5cad8086
[GC (Allocation Failure) --[PSYoungGen: 5632K->5632K(6144K)] 17920K->17957K(19968K), 0.0033787 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 5632K->0K(6144K)] [ParOldGen: 12325K->393K(8704K)] 17957K->393K(14848K), [Metaspace: 3153K->3153K(1056768K)], 0.0027499 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
null null null null [B@6e0be858
null null null null [B@6e0be858 [B@61bbe9ba
null null null null [B@6e0be858 [B@61bbe9ba [B@610455d6
null null null null [B@6e0be858 [B@61bbe9ba [B@610455d6 [B@511d50c0
[GC (Allocation Failure) --[PSYoungGen: 4380K->4380K(6144K)] 17061K->17061K(19968K), 0.0004614 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 4380K->0K(6144K)] [ParOldGen: 12681K->374K(13824K)] 17061K->374K(19968K), [Metaspace: 3158K->3158K(1056768K)], 0.0028986 secs] [Times: user=0.00 sys=0.01, real=0.01 secs]
null null null null null null null null [B@60e53b93
null null null null null null null null [B@60e53b93 [B@5e2de80c
running over, list size: 10
Heap
PSYoungGen total 6144K, used 4378K [0x00000007bf980000, 0x00000007c0000000, 0x00000007c0000000)
eden space 5632K, 77% used [0x00000007bf980000,0x00000007bfdc6828,0x00000007bff00000)
from space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
to space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
ParOldGen total 13824K, used 4470K [0x00000007bec00000, 0x00000007bf980000, 0x00000007bf980000)
object space 13824K, 32% used [0x00000007bec00000,0x00000007bf05da48,0x00000007bf980000)
Metaspace used 3169K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 349K, capacity 388K, committed 512K, reserved 1048576K
2. 垃圾回收算法
当我们通过可达性分析法来定位对象是否存活后,我们就需要通过某种策略把这些已死的对象进行清理、然后对存活的对象进行整理,这个过程就涉及到三种算法,分别为标记清除法、标记复制法、标记整理法。
2.1 标记清除法
标记清除法是先找到内存里的存活对象并对其进行标记,然后统一把未标记的对象统一的清理,过程大致如下。
标记清除优势:标记清除法的特点就是简单直接,速度也非常快,适合存活对象多,需要回收的对象少的场景。
标记清除不足:
-
会造成不连续的内存空间:就像上图清除后的内存区域一样,清除后内存会有很多不连续的空间,这也就是我们常说的空间碎片,这样的空间碎片太多不仅不利于我们下次分配,而且当有大对象创建的时候,我们明明有可以容纳的总空间,但是空间都不是连续的造成对象无法分配,从而不得不提前触发GC。
-
性能不稳定:内存中需要回收的对象,当内存中大量对象都是需要回收的时候,通常这些对象可能比较分散,所以清除的过程会比较耗时,这个时候清理的速度就会比较慢了。
2.2 标记复制法
标记清除法最大问题是会造成空间碎片,同时可回收对象如果太多也会影响其性能,而标记复制法则可以解决这两大问题。标记清除法的关注点在可回收的对象身上,而标记复制法的关注点则放在了存活的对象身上,通过把存活的对象转移到一个新的区域,然后对原区域的对象进行统一清理。
首先它把内存划分出三块区域,一块用于存放新创建的对象叫Eden区,另外两块则用于存放存活的对象分别叫 S1区和S2区。回收的时候会有两种情况,一种是把Eden和S1区的存活对象复制到S2区,第二种是把Eden和S2区的存活对象复制到S1区 ,也就是说S1区和S2这两块区域同时只会有一块使用,通过这种方式保证始终会有一块空白的区域用于下次GC时存放存活的对象,而且原来的区域不需要考虑保留存活的对象,所以可以直接一次性清除所有对象,这要既简单直接同时也保证了清除的内存区域的内存连续性。
标记复制法的优势:
标记复制法解决了标记清除法的空间碎片问题,并且采用移动存活对象的方式,每次清除针对的都是一整块内存,所以清除可回收对象的效率也比较高,但因为要移动对象所以这里会耗费一部分时间,所以标记复制法效率还是会低于标记清除法。
标记复制法的不足:
-
会浪费一部分空间:通过上面的图我们也不难发现,总是会有一块空闲的内存区域是利用不到的,这也造成了资源的浪费。
-
存活对象多会非常耗时:因为复制移动对象的过程是比较耗时的,这个不仅需要移动对象本身,还需要修改使用了这些对象的引用地址,所以当存活对象多的场景会非常耗时,所以标记复制法比较适合存活对象较少的场景。
-
需要担保机制:因为复制区总会有一块空间的浪费,而为了减少浪费空间太多,所以我们会把复制区的空间分配控制在很小的区间,但是空间太小又会产生一个问题,就是在存活的对象比较多的时候,这时复制区的空间可能不够容纳这些对象,这时就需要借一些空间来保证容纳这些对象,这种从其他地方借内存的方式我们称它为担保机制。
2.3 标记整理法
标记复制法算是完美的补齐了标记清除法的短板,既解决了空间碎片的问题,又适合使用在大部分对象都是可回收的场景。 不过标记复制法也有不完美的地方,一方面是需要空闲出一块内存空间用来腾挪对象,另外一方面它在存活对象比较多的场景也不是太适合,而存活对象多的场景通常适合使用标记清除法,但是标记清除法会产生空间碎片又是一个无法忍受的问题。
所以就需要有一种算法,专门针对存活对象多,但是又不产生空间碎片,还不浪费内存空间,这就是标记整理法的初衷。标记整理法的思路很好理解,好像我们整理房间一样,就是把有用的东西和需要丢弃的垃圾分别挪到房间的两边,然后再把房间垃圾的那一侧整体扫地出门。
标记整理法分为标记和整理两个阶段,标记阶段会先把存活的对象和可回收的对象标记出来;标记完再对内存对象进行整理,这个阶段会把存活的对象往内存的一端移动,移动完对象后再清除存活对象边界之外的对象。
标记整理法的优势:
标记整理法是解决了标记复制法浪费空间、不适合存活对象多场景的短板,又解决了标记清除法空间碎片的短板, 所以对于标记复制法不适合的场景,同时又不能忍受标记清除法的空间碎片问题,就可以考虑标记整理法。
标记整理法的不足:
没有任何一种算法是万能的,标记整理法看似解决了很多问题,但它本身存在很严重的性能问题,标记整理法是三种垃圾回收算法中性能最低的一种,因为标记整理法在移动对象的时候不仅需要移动对象,还要额外的维护对象的引用的地址,这个过程可能要对内存经过几次的扫描定位才能完成,同时还有清除对象的空座,既然做的事情这么多那么必然消耗的时间也越多。
2.4 各种垃圾回收算法的适用场景
我们了解了三种垃圾回收算法后会发现,没有一个算法是完美,每种算法都有自己的特点,所以我们只能根据具体的场景去选择合适的垃圾收集算法。
1、标记清除法
特点: 简单、收集速度快,但会有空间碎片,空间碎片会导致后面的GC频率增加。
适合场景:只有小部分对象需要进行回收的,所以标记清除法比较适用于老年代的垃圾回收,因为老年代一般存活对象会比回收对象要多。
2、标记复制法
特点:收集速度快,可以避免空间碎片,但是有空间浪费,存活对象较多的情况下复制对象的过程等会非常耗时,而且需要担保机制。
适合场景: 只有少量对象存活的场景,这也正是新生代对象的特点,所以一般新生代的垃圾回收器基本都会选择标记复制法。
3、标记整理法
特点: 相对于标记复制法不会浪费内存空间,相对标记清除法则可以避免空间碎片,但是速度比其他两个算法慢。
适合场景: 内存吃紧,又要避免空间碎片的场景,老年代想要避免空间碎片问题的话通常会使用标记整理法。
3. 分代垃圾回收
3.1 分代垃圾回收
实际情况下的JVM垃圾回收会针对具体情况协调几种垃圾回收算法,具体实现称作分代垃圾回收机制。整个堆内存的大区域划分为新生代和老年代,新生代又划分为伊甸园、幸存区From、幸存区To。这样划分是因为有些对象需要长时间使用,这些对象放在老年代中;而有些对象不需要长时间使用,则放在新生代。针对不同区域使用不同的垃圾回收算法。
- 新对象会将对象创建在伊甸园区域
- 当伊甸园内存无法再容纳新对象时会触发Minor GC(采用可达性分析算法),然后将幸存的对象采用复制算法复制到幸存区To,使其年龄 + 1,并交换From和To区域
- Minor GC会触发STW(stop the world)——暂停其他用户线程,垃圾回收线程完成回收后才恢复用户线程运行
- 当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(4bit,最大十进制值为15)
- 当老年代空间不足,会先尝试触发Minor GC,如果空间仍然不足则触发Full GC,STW时间更长
- Full GC之后老年代空间仍不足,则触发Out Of Memory Error: Java Heap Space
3.2 相关JVM参数
3.3 GC分析
运行以下程序,观察GC输出详情与堆内存变化
初始情况
/**
* -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:PrintGCDetails -verbose:gc
*/
public class Main {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;
public static void main(String[] args) {
}
}
Heap
def new generation total 9216K, used 1731K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
eden space 8192K, 21% used [0x00000007bec00000, 0x00000007bedb0f58, 0x00000007bf400000)
from space 1024K, 0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
to space 1024K, 0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
tenured generation total 10240K, used 0K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
the space 10240K, 0% used [0x00000007bf600000, 0x00000007bf600000, 0x00000007bf600200, 0x00000007c0000000)
Metaspace used 3159K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 348K, capacity 388K, committed 512K, reserved 1048576K
- def new generation:新生代,设置-Xmx20M,实际为9216K。因为幸存区To默认为不可用区域,需要减去该区域占用内存
- tenured generation:老年代
- Metaspace:元空间
小对象创建
/**
* -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:PrintGCDetails -verbose:gc
*/
public class Main {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);
list.add(new byte[_512KB]);
}
}
[GC (Allocation Failure) [DefNew: 1567K->388K(9216K), 0.0015099 secs] 1567K->388K(19456K), 0.0016601 secs] [Times: user=0.01 sys=0.01, real=0.00 secs]
Heap
def new generation total 9216K, used 8478K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
eden space 8192K, 98% used [0x00000007bec00000, 0x00000007bf3e6838, 0x00000007bf400000)
from space 1024K, 37% used [0x00000007bf500000, 0x00000007bf561160, 0x00000007bf600000)
to space 1024K, 0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
tenured generation total 10240K, used 0K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
the space 10240K, 0% used [0x00000007bf600000, 0x00000007bf600000, 0x00000007bf600200, 0x00000007c0000000)
Metaspace used 3153K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 347K, capacity 388K, committed 512K, reserved 1048576K
小对象创建,触发GC
/**
* -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:PrintGCDetails -verbose:gc
*/
public class Main {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);
list.add(new byte[_512KB]);
list.add(new byte[_512KB]);
}
}
[GC (Allocation Failure) [DefNew: 1567K->392K(9216K), 0.0012920 secs] 1567K->392K(19456K), 0.0014405 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [DefNew: 8400K->880K(9216K), 0.0028952 secs] 8400K->8048K(19456K), 0.0029182 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
def new generation total 9216K, used 1801K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
eden space 8192K, 11% used [0x00000007bec00000, 0x00000007bece60f0, 0x00000007bf400000)
from space 1024K, 86% used [0x00000007bf400000, 0x00000007bf4dc390, 0x00000007bf500000)
to space 1024K, 0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
tenured generation total 10240K, used 7168K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
the space 10240K, 70% used [0x00000007bf600000, 0x00000007bfd00010, 0x00000007bfd00200, 0x00000007c0000000)
Metaspace used 3160K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 348K, capacity 388K, committed 512K, reserved 1048576K
大对象创建
/**
* -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:PrintGCDetails -verbose:gc
*/
public class Main {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_8MB]);
}
}
Heap
def new generation total 9216K, used 1731K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
eden space 8192K, 21% used [0x00000007bec00000, 0x00000007bedb0f58, 0x00000007bf400000)
from space 1024K, 0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
to space 1024K, 0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
tenured generation total 10240K, used 8192K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
the space 10240K, 80% used [0x00000007bf600000, 0x00000007bfe00010, 0x00000007bfe00200, 0x00000007c0000000)
Metaspace used 3151K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 347K, capacity 388K, committed 512K, reserved 1048576K
大对象创建触发OOM,导致主线程结束
/**
* -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:PrintGCDetails -verbose:gc
*/
public class Main {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_8MB]);
list.add(new byte[_8MB]);
}
}
[GC (Allocation Failure) [DefNew: 1567K->388K(9216K), 0.0012293 secs][Tenured: 8192K->8579K(10240K), 0.0014159 secs] 9759K->8579K(19456K), [Metaspace: 3059K->3059K(1056768K)], 0.0028190 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [Tenured: 8579K->8562K(10240K), 0.0008542 secs] 8579K->8562K(19456K), [Metaspace: 3059K->3059K(1056768K)], 0.0008647 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 410K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
eden space 8192K, 5% used [0x00000007bec00000, 0x00000007bec66800, 0x00000007bf400000)
from space 1024K, 0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
to space 1024K, 0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
tenured generation total 10240K, used 8562K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
the space 10240K, 83% used [0x00000007bf600000, 0x00000007bfe5c8a8, 0x00000007bfe5ca00, 0x00000007c0000000)
Metaspace used 3179K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 350K, capacity 388K, committed 512K, reserved 1048576K
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.tzuaness.jvm.Demo02GCPrintDetails.main(Demo02GCPrintDetails.java:19)
线程中创建大对象触发OOM,未导致主线程结束
/**
* -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:PrintGCDetails -verbose:gc
*/
public class Demo02GCPrintDetails {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_8MB]);
list.add(new byte[_8MB]);
}).start();
Thread.sleep(1000);
System.out.println("over...");
}
}
[GC (Allocation Failure) [DefNew: 3898K->623K(9216K), 0.0031331 secs][Tenured: 8192K->8813K(10240K), 0.0031949 secs] 12090K->8813K(19456K), [Metaspace: 4095K->4095K(1056768K)], 0.0065025 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[Full GC (Allocation Failure) [Tenured: 8813K->8757K(10240K), 0.0019078 secs] 8813K->8757K(19456K), [Metaspace: 4095K->4095K(1056768K)], 0.0019295 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Exception in thread "Thread-0" java.lang.OutOfMemoryError: Java heap space
at com.tzuaness.jvm.Demo02GCPrintDetails.lambda$main$0(Demo02GCPrintDetails.java:20)
at com.tzuaness.jvm.Demo02GCPrintDetails$$Lambda$1/1480010240.run(Unknown Source)
at java.lang.Thread.run(Thread.java:750)
over...
Heap
def new generation total 9216K, used 546K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
eden space 8192K, 6% used [0x00000007bec00000, 0x00000007bec88b98, 0x00000007bf400000)
from space 1024K, 0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
to space 1024K, 0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
tenured generation total 10240K, used 8757K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
the space 10240K, 85% used [0x00000007bf600000, 0x00000007bfe8d668, 0x00000007bfe8d800, 0x00000007c0000000)
Metaspace used 4123K, capacity 4676K, committed 4864K, reserved 1056768K
class space used 461K, capacity 496K, committed 512K, reserved 1048576K
4. 垃圾回收器
4.1 串行
- 单线程
- 堆内存较小,适合个人电脑
- 开启串行垃圾回收:-XX:+UseSerialGC=Serial+SerialOld
4.2 吞吐量优先
- 多线程
- 堆内存较大,多核CPU
- 尽可能使单位时间内STW的时间最短(0.2 + 0.2 = 0.4)
- 相关JVM参数:参数3、4为互斥关系,且都与参数2相关
- -XX:+UseParallelGC ~ -XX:+UseParallelOldGC(开启其中一个,另一个也默认开启。1.8默认打开)
- -XX:+UseAdaptiveSizePolicy,采用自适应大小调整策略,在ParallelGC发生时动态调整伊甸园Survivor区
- -XX:GCTimeRatio=ratio,计算公式为
1/(1+ratio)
。程序运行总时间为total,如果垃圾回收用时超过total/(1+ratio)
,则会调大堆内存以减小GC次数,从而尽量达到此目标值(提升吞吐量,堆调大后,虽然总GC用时减少,但每次GC用时会增加)。默认值为99,一般会设为19 - -XX:MaxGCPauseMillis=ms,每次GC最长用时,该值与参数3相斥(因为该值调小意味着堆内存需要变小,以减小平均GC用时)。默认值为200
- -XX:ParallelGCThreads=n,并行垃圾回收的线程数
4.3 响应时间优先
- 多线程
- 堆内存较大,多核CPU
- 尽可能使每次STW的时间最短(0.1 + 0.1 + 0.1 + 0.1 + 0.1 = 0.5)
- 相关JVM参数:
- -XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld,在GC线程运行时,用户线程也可以运行
- -XX:ParallelGCThreads=n ~ -XX:ConcGCTreads=threads,并行GC线程数和并发GC线程数,一般threads=n/4
- -XX:CMSInitiatingOccupancyFraction=percent,设置触发CMSGC的老年代内存占用百分比,假如设置为80%,则当老年代内存占用到达80%时触发CMS,另外20%预留给浮动垃圾。默认值为65%
- -XX:+CMSScavengeBeforeRemark
CMS垃圾收集器有两点缺陷,其一是无法处理浮动垃圾,即在并发清理的过程中,用户线程又产生了新的垃圾,这些垃圾无法标记只能等到下次FullGC进行清理。所以老年代需要预留一定的空间装下浮动垃圾,在JDK1.6版本下,CMS启动阈值为92%,即老年代内存超过92%启动CMS。在这个过程中就可能导致并发失败,即CMS运行时内存空间无法满足,这时虚拟机才将Serial Old拿出来并且STW,进行串行的垃圾清理。
4.4 G1
定义:Garbage First
历史:
- 2004 论文
- 2009 JDK 6u14 体验
- 2012 JDK 7u4 官方支持
- 2017 JDK 9 支持
特点:
- 同时注重吞吐量(Throughput)和低延迟(Low latency),默认的暂停目标是200ms
- 适用于超大堆内存(会将堆划分为多个大小相等的 Region)
- 整体上是标记+整理算法,两个区域之间是复制算法
相关JVM参数:
- -XX:+UseG1GC
- -XX:G1HeapReginSize=size
- -XX:MaxGCPauseMillis=time
4.4.1 G1 垃圾回收阶段
4.4.2 Young Collection
G1垃圾回收器将堆内存划分为多个大小相等的区Region,其中E为划分为伊甸园的区域Eden。
Eden区空间占满后,触发Young GC,使用复制算法将幸存对象复制到Survivor区
Survivor区空间占满后,会将大龄的对象复制到Old区,年龄较小的仍留下来,年龄+1
4.4.3 Young Collection + CM
在Young GC时会进行GC Root的初始标记
老年代占用堆空间比例达到阈值时,进行并发标记(不会STW),阈值由此JVM参数决定:-XX:InitiatingHeapOccupancyPercent=percent(默认45%)
4.4.4 Mixed Collection 混合垃圾回收
会对E、S、O进行全面垃圾回收
- 最终标记(Remark)会STW,目的是回收在并发标记时漏掉的垃圾对象(在并发标记时其他用户线程工作产生的垃圾对象)
- 拷贝存活(Evacuation)会STW
-XX:MaxGCPauseMillis=ms
4.4.5 Full GC 辩析
SerialGC与ParallelGC
- 新生代内存不足发生的垃圾回收:minor gc
- 老年代内存不足发生的垃圾回收:full gc
CMS与G1
- 新生代内存不足发生的垃圾回收:minor gc
- 老年代内存不足,触发并发标记与混合回收时,如果垃圾回收速度高于垃圾产生速度,此时不是full gc,还是并发垃圾回收;只有在垃圾回收速度低于垃圾产生速度时,并发垃圾回收会失败,会退化为串行垃圾回收而发生full gc,此时会进行更长时间的STW
4.4.6 Young Collection 跨代引用(细节问题)
新生代回收的跨代引用(老年代引用新生代)问题
- 卡表与Remembered Set
- 在引用变更时通过post-write barrier + dirty card queue
- concurrent refinement threads 更新Remembered Set
4.4.7 Remark(细节问题)
pre-write barrier + satb_mark_queue
并发标记刚结束时,如果一个被标记为垃圾的对象再次由于其他用户线程的工作成为了GC Root引用链中的一员,此时会触发写屏障,将该对象加入队列中并标记为幸存对象,在重标记(最终标记)时,STW后会重新检查队列中的对象是否为垃圾对象。
4.4.8 JDK 8u20 字符串去重
-XX:+UseStringDeduplication(默认启用)
String s1 = new String("hello"); // char[]{'h', 'e', 'l', 'l', 'o'}
String s2 = new String("hello"); // char[]{'h', 'e', 'l', 'l', 'o'}
将所有新分配的字符串放入一个队列中,当新生代发生垃圾回收时,G1并发检查是否存在字符串重复,使值重复的字符串引用同一个char[]
优点:节省大量内存
缺点:略微多占用了CPU时间,新生代回收时间略微增加
注意:其优化原理与String.inter()不同
- String.intern()关注的是字符串对象不重复
- 此字符串去重关注的是char[]不重复
- 在JVM内部,它们使用了不同的字符串表
4.4.9 JDK 8u40 并发标记类卸载
所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它加载的所有类
-XX:+ClassUnloadingWithConcurrentMark(默认启用)
4.4.10 JDK 8u60 回收巨型对象
- 一个对象大于region的一半时,称之为巨型对象
- G1不会对巨型对象进行拷贝
- 回收时被优先考虑
- G1会跟踪老年代所有incoming引用,这样老年代incoming引用为0的巨型对象就可以在新生代垃圾回收时处理掉。(比如下图中右上角的H,此时O区域中的卡表未再引用它,它会在新生代垃圾回收时被回收)
4.4.11 JDK 9 并发标记起始时间的调整
- 并发标记必须在堆空间占满前完成,否则退化为Full GC
- JDK 9之前需要-XX:InitiatingHeapOccupancyPercent设置触发并发标记的初始值
- JDK 9可以进行数据采样并动态调整该值,会添加一个安全的空档空间来容纳浮动垃圾,尽可能避免退化为Full GC
4.4.12 JDK 9 更高效的回收
- 250+增强
- 180+bug修复
5. 垃圾回收调优
使用命令可查看当前JVM的GC相关参数配置java -XX:+PrintFlagsFinal -version | grep GC
5.1 调优领域
- 内存
- 锁竞争
- CPU占用
- IO
5.2 确定目标
根据需求是【低延迟】还是【高吞吐量】,选择合适的垃圾回收器
- CMS,G1,ZGC
- ParallelGC
- Zing
5.3 最快的GC是不发生GC
查看Full GC前后内存占用,考虑以下几个问题
- 数据是否太多?
- resultSet = statement.executeQuery(“SELECT * FROM big_table;”);
- 数据表示是否太臃肿?
- 对象图,例:查询出来的对象图比实际需要用到的数据多得多
- 对象大小:例:Integer中对象头16字节,4字节int值,对齐后占用32字节,而int只需要4字节
- 是否存在内存泄露
- 例:static Map map = new HashMap<>(),不断往其中放数据会触发OOM。可以使用软引用、弱引用优化
5.4 新生代调优
新生代特点:
- 所有new操作的内存分配都非常廉价,因为在对象创建时会有TLAB(thread-local allocation buffer)
- 死亡对象的回收代价是零,因为垃圾回收采用复制算法时,会将Eden与Survivor From的存活对象复制到Survivor To中。剩下的空间都被清空
- 大部分对象用过即死
- Minor GC的时间远远低于Full GC
新生代(-Xmn)越大越好吗?
Oracle原文:
-Xmn设置堆中年轻代的初始值和最大值,GC在年轻代区域发生的频率比其他区域高。如果年轻代太小会导致频繁Minor GC;太大会导致仅有Full GC而占用大量时间。建议年轻代占整个堆内存的25%~50%
具体设置为多大较为合适:理想情况下一次请求与响应过程中产生的对象与并发量之积。假设一次请求大约产生了512KB对象,有1000次并发请求,那么就是512KB * 1000 ~ 512MB
幸存区调优
- 幸存区大到能保留【当前活跃对象+需要晋升对象】
- 晋升阈值配置得当,让长时间存活对象尽快晋升(如果晋升过慢,From与To多次进行对象复制会耗费较多时间,Minor GC主要时间花费就在对象复制)
调整最大晋升阈值:-XX:MaxTenuringThreshold=threshold
幸存区对象年龄与占用监控:-XX:+PrintTenuringDistribution
左列为该年龄的对象总占用的内存,右列尾小于等于该年龄的对象总占用的内存
5.5 老年代调优
以CMS为例:
- CMS的老年代内存越大越好(防止CMS垃圾回收退化为Serial Old)
- 先尝试不做调优,如果没有发生Full GC,那么说明老年代很充裕无需优化,即使发生了Full GC,也需要优先考虑调优新生代
- 观察发生Full GC时老年代内存占用,将老年代内存预设调大1/4 ~ 1/3
-XX:CMSInitiatingOccupancyFractioin=percent
5.6 案例
1、Minor GC和Full GC频繁
分析:可能是因为新生代较小,导致Minor GC频繁,进一步导致进入老年代的对象变多,从而导致老年代也频繁发生Full GC。
解决:可以尝试调大新生代,降低Minor GC频率,并调高新生代对象晋升老年代的阈值,以减少老年代占用
2、请求高峰期发生Full GC,单次暂停时间特别长(CMS)
分析:查看GC日志,GC每个阶段所耗费的时间会显示在其中。CMS垃圾回收器的重新标记阶段比较耗时,判断日志是否符合该情况
解决:符合时可使用-XX:+CMSScavengeBeforeRemark参数,在重标记前先进行垃圾回收,以减少重标记所耗费时间
3、老年代充裕情况下,发生Full GC(CMS JDK1.7)
分析:元空间内存不足导致Full GC
解决:增大元空间内存占用
四、 类加载与字节码技术
1. 类加载过程
1.1 类文件结构
1.2 加载
将类的字节码载入方法区中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
- _java_mirror 即 java 的类镜像,例如对 String 来说,就是 String.class,作用是把 klass 暴
露给 java 使用 - _super 即父类
- _fields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- _itable 接口方法表
如果这个类还有父类没有加载,先加载父类。加载和链接可能是交替运行的
注意:
- instanceKlass 这样的【元数据】是存储在方法区(1.8 后的元空间内),但 _java_mirror
是存储在堆中 - 可以通过前面介绍的 HSDB 工具查看
- instanceKlass 与 class 的对应关系如下图:
1.3 链接
该阶段包含验证、准备、解析:
- 验证:验证类是否符合 JVM规范,安全性检查
- 准备:为 static 变量分配空间,设置默认值
- static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
- static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
- 如果 static 变量是 final 的引用类型,那么赋值也会在初始化阶段完成
- 解析:将常量池中的符号引用解析为直接引用
/* 解析的含义 */ public class Load2 { public static void main(String[] args) throws ClassNotFoundException, IOException { ClassLoader classloader = Load2.class.getClassLoader(); // loadClass() 只涉及类的加载,不会进行类的解析和初始化 Class<?> c = classloader.loadClass("cn.itcast.jvm.t3.load.C"); new C(); // 会进行类的初始化操作 System.in.read(); } } class C { D d = new D(); } class D { }
1.4 初始化
初始化即调用 <cinit>()V 方法,虚拟机会保证这个类的『构造方法』的线程安全。概括得说,类初始化是【懒惰的】
类初始化发生的时机
- main 方法所在的类,总会被首先初始化
- 首次访问这个类的静态变量或静态方法时
- 子类初始化,如果父类还没初始化,会引发
- 子类访问父类的静态变量,只会触发父类的初始化
- Class.forName() 方法
- new 关键字
不会导致类初始化的情况
- 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
- 类对象.class 不会触发初始化,如String.class
- 创建该类的数组不会触发初始化,如new String[0]
- 类加载器的 loadClass 方法
- Class.forName 的第 2 个参数为 false 时
练习: 懒加载单例模式
// 懒加载单例模式
class Singleton {
private Singleton() {
}
private static class LazyHolder {
private static final Singleton INSTANCE = new Singleton();
}
// 第一次调用该方法,才会导致内部类加载和初始化其静态成员
public Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
2. 类加载器
以 JDK 8 为例:
名称 | 加载目录 | 说明 |
---|---|---|
Bootstrap ClassLoader | JAVA_HOME/jre/lib | 启动类加载器,由C++实现,用户无法直接访问 |
Extension ClassLoader | JAVA_HOME/jre/lib/ext | 扩展类加载器,上级为系统类加载器 |
Application ClassLoader | classpath | 应用程序类加载器,上级为扩展类加载器 |
自定义类加载器 | 自定义 | 用户自己定义的加载器,上级为应用程序类加载器 |
一般包名以java开头的类(位于lib目录下),就是通过启动类加载器加载的,我们自己写的java代码,是由应用程序类加载器加载
2.1 启动类加载器
使用启动类加载器加载类:
// 使用此命令运行该类:java -Xbootclasspath/a:. BootstrapClassLoaderTest
public class BootstrapClassLoaderTest {
public static void main(String[] args) {
// 如果使用的是BootstrapClassLoader,打印结果为null
System.out.println(BootstrapClassLoaderTest.class.getClassLoader());
}
}
- -Xbootclasspath 表示设置 bootclasspath
- 其中 /a:. 表示将当前目录追加至 bootclasspath 之后
- 可以用这个办法替换核心类
- java -Xbootclasspath:
- java -Xbootclasspath/a:<追加路径>
- java -Xbootclasspath/p:<追加路径>
2.2 扩展类加载器
使用扩展类加载器加载类:
1. 将字节码文件打成jar包:jar -cvf extclassloadertest.jar ExtensionClassloaderTest.class
2. 将jar包拷贝到JAVA_HOME/jre/lib/ext
3. 执行该文件,输出结果sun.misc.Launcher$ExtClassLoader@29453f44
2.3 双亲委派机制
所谓的双亲委派,就是指调用类加载器的 loadClass 方法时,查找类的规则。ClassLoader接收到类加载请求时,并不会亲自加载该类,而是委托上级的类加载器加载,层层向上直到最上级类加载器(启动类加载器)。当ClassLoader在加载路径下无法找到该类,才交给下级的类加载器加载
相关Java代码实现:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查该类是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 2. 有上级的话,委派上级
loadClass c = parent.loadClass(name, false);
} else {
// 3. 如果没有上级了(ExtClassLoader),则委派 BootstrapClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
long t1 = System.nanoTime();
// 4. 每一层找不到,调用 findClass 方法(每个类加载器自己扩展)来加载
c = findClass(name);
// 5. 记录耗时
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
例如:
public class Load5_3 {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> aClass = Load5_3.class.getClassLoader() .loadClass("com.ziang.jvm.t3.load.H");
System.out.println(aClass.getClassLoader());
}
}
执行流程为:
- sun.misc.Launcher$AppClassLoader //1 处, 开始查看已加载的类,结果没有
- sun.misc.Launcher$AppClassLoader // 2 处,委派上级
sun.misc.Launcher$ExtClassLoader.loadClass() - sun.misc.Launcher$ExtClassLoader // 1 处,查看已加载的类,结果没有
- sun.misc.Launcher$ExtClassLoader // 3 处,没有上级了,则委派 BootstrapClassLoader
查找 - BootstrapClassLoader 是在 JAVA_HOME/jre/lib 下找 H 这个类,显然没有
- sun.misc.Launcher$ExtClassLoader // 4 处,调用自己的 findClass 方法,是在
JAVA_HOME/jre/lib/ext 下找 H 这个类,显然没有,回到 sun.misc.Launcher$AppClassLoader 的 // 2 处 - 继续执行到 sun.misc.Launcher$AppClassLoader // 4 处,调用它自己的 findClass 方法,在
classpath 下查找,找到了
2.4 线程上下文类加载器
我们在使用 JDBC 时,都需要加载 Driver 驱动,不知道你注意到没有,不写Class.forName("com.mysql.jdbc.Driver")
也是可以让 com.mysql.jdbc.Driver 类正确加载的,你知道是怎么做的吗?
先不看别的,看看 DriverManager 的类加载器:执行System.out.println(DriverManager.class.getClassLoader());
,输出结果为null。表示它的类加载器是 Bootstrap ClassLoader,会到 JAVA_HOME/jre/lib 下搜索类,但 JAVA_HOME/jre/lib 下显然没有 mysql-connector-java-5.1.47.jar 包,这样问题来了,在 DriverManager 的静态代码块中,怎么能正确加载 com.mysql.jdbc.Driver 呢?
让我们追踪一下源码:
public class DriverManager {
// 注册驱动的集合
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
// 初始化驱动
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
继续看 loadInitialDrivers() 方法:
private static void loadInitialDrivers() {
String drivers;
try {
drivers = AccessController.doPrivileged(new PrivilegedAction<String> () {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
// 1)使用 ServiceLoader 机制加载驱动,即 SPI
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
try{
while (driversIterator.hasNext()) {
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
return null;
}
});
println("DriverManager.initialize: jdbc.drivers = " + drivers);
// 2)使用 jdbc.drivers 定义的驱动名加载驱动
if (drivers == null || drivers.equals("")) {
return;
}
String[] driversList = drivers.split(":");
println("number of Drivers:" + driversList.length);
for (String aDriver : driversList) {
try {
println("DriverManager.Initialize: loading " + aDriver);
// 这里的 ClassLoader.getSystemClassLoader() 就是应用程序类加载器
Class.forName(aDriver, true, ClassLoader.getSystemClassLoader());
} catch (Exception ex) {
println("DriverManager.Initialize: load failed: " + ex);
}
}
}
先看 2)发现它最后是使用 Class.forName 完成类的加载和初始化,关联的是应用程序类加载器因此可以顺利完成类加载
再看 1)它就是大名鼎鼎的 Service Provider Interface (SPI)约定如下:
在 jar 包的 META-INF/services 包下,以接口全限定名作为文件名,文件内容是实现类名称
这样就可以使用如下代码得到实现类,体现的是【面向接口编程+解耦】的思想:
ServiceLoader<接口类型> allImpls = ServiceLoader.load(接口类型.class); // 接口类型:java.sql.Driver
Iterator<接口类型> iter = allImpls.iterator();
while (iter.hasNext()) {
iter.next();
}
在下面一些框架中都运用了此思想:
- JDBC
- Servlet 初始化器
- Spring 容器
- Dubbo(对 SPI 进行了扩展)
接着看 ServiceLoader.load 方法:
public static <S> ServiceLoader<S> load(Class<S> service) {
// 获取线程上下文类加载器
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
线程上下文类加载器是当前线程使用的类加载器,默认就是应用程序类加载器,ServiceLoader.load(service, cl)内部又是由Class.forName() 调用了线程上下文类加载器完成加载,具体代码在ServiceLoader的内部类LazyIterator中:
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
// 此处的loader就是上一步中的cl
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service, "Provider " + cn + " not found");
}
if (!service.isAssignableFrom(c)) {
fail(service, "Provider " + cn + " not a subtype");
}
try {
S p = service.cast(c.newInstance());
providers.put(cn, p);
return p;
} catch (Throwable x) {
fail(service, "Provider " + cn + " could not be instantiated", x);
}
throw new Error(); // This cannot happen
}
2.5 自定义类加载器
问问自己,什么时候需要自定义类加载器:
- 想加载非 classpath 随意路径中的类文件
- 都是通过接口来使用实现,希望解耦时,常用在框架设计
- 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器
步骤:
- 继承 ClassLoader 父类
- 要遵从双亲委派机制,重写 findClass 方法(注意不是重写 loadClass 方法,否则不会走双亲委派机制)
- 读取类文件的字节码
- 调用父类的 defineClass 方法来加载类
- 使用者调用该类加载器的 loadClass 方法
示例:
class MyClassloader extends Classloader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String path = "\\myclasspath\\" + name + ".class";
try {
ByteArrayOutputStream os = new ByteArrayOutputStream();
Files.copy(Paths.get(path), os);
// 得到字节数组
byte[] bytes = os.toByteArray();
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
e.printStackTrace();
throw new ClassNotFoundExeception("类文件未找到:", path);
}
}
}
五、运行期优化
1. 即时编译
分层编译:
先来个例子
public class JIT1 {
public static void main(String[] args) {
for (int i = 0; i < 200; i++) {
long start = System.nanoTime(); for (int j = 0; j < 1000; j++) {
new Object(); }long end = System.nanoTime(); System.out.printf("%d\t%d\n",i,(end - start)); } } }
观察输出的时间间隔,会发现刚开始可能是5位数,但后来变为了3位数
原因是什么呢?
JVM 将执行状态分成了 5 个层次:
- 0 层,解释执行(Interpreter)
- 1 层,使用 C1 即时编译器编译执行(不带 profiling)
- 2 层,使用 C1 即时编译器编译执行(带基本的 profiling)
- 3 层,使用 C1 即时编译器编译执行(带完全的 profiling)
- 4 层,使用 C2 即时编译器编译执行
profiling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的
回边次数】等
即时编译器(JIT)与解释器的区别
- 解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
- JIT 是将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
- 解释器是将字节码解释为针对所有平台都通用的机器码
- JIT 会根据平台类型,生成平台特定的机器码
对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。 执行效率上简单比较一下 Interpreter < C1 < C2,总的目标是发现热点代码(hotspot名称的由来),优化之刚才的一种优化手段称之为【逃逸分析】,发现新建的对象是否逃逸。可以使用 -XX:-DoEscapeAnalysis 关闭逃逸分析,再运行刚才的示例观察结果
方法内联
public static void main(String[] args) {
int x = 0;
for (int i = 0; i < 500; i++) {
long start = System.nanoTime();
for (int j = 0; j < 1000; j++) {
x = square(9);
}
long end = System.nanoTime();
System.out.printf("%d\t%d\t%d\n",i,x,(end - start));
}
}
private static int square(final int i) {
return i * i;
}
如果发现 square 是热点方法,并且长度不太长时,会进行内联,所谓的内联就是把方法内代码拷贝、粘贴到调用者的位置:
System.out.println(9 * 9);
还能够进行常量折叠(constant folding)的优化
System.out.println(81);
2. 其他优化
其他优化如:字段优化、反射优化。更多优化细节可以在网上查询,这不再过多描述