参考视频:满神Java虚拟机快速入门
笔记的整体结构依据视频编写,并随着学习的深入补充了很多知识
目录指引:
什么是JVM
定义
Java Virtual Machine,Java程序的运行环境(JAVA二进制字节码的运行环境)
好处
- 一次编写,到处运行(JVM屏蔽了我们的字节码跟底层操作系统之间的差异,对外提供一个一致的运行环境,JVM就可以用这种解释的方法来执行我们的二进制的字节码,以达到我们代码的一个平台无关性)
- 自动内存管理,垃圾回收机制(早期与C语言竞争)
- 数组下标越界检查(数组的新元素覆盖了程序的其他部分比抛异常严重多了,之前C语言是没有数组下标检查的,程序员必须自己去捕捉异常,一旦数组下标越界就有可能覆盖其他代码的内存,是很严重的)
- 多态(让代码的扩展性得到大大提升,JVM在内部使用虚方法表的机制来实现了多态)

理解底层的实现原理(自动拆装箱,for each增强,动态代理等,都需要掌握一定的字节码技术)
JVM内存结构

JVM分三大块:类加载器、JVM内存结构、执行引擎
Java源代码编译为二进制字节码后必须经过类加载器才可以被加载到JVM里去运行,类都是放在这个方法区里,类将来创建的实例对象都放在堆里,堆中的实例对象将来调用方法时又会用到虚拟机栈,程序计数器,本地方法栈;
方法执行时,每行代码是由执行引擎中的解释器逐行执行,方法中的热点代码或频繁调用的代码会由一个即时编译器进行一个优化;执行引擎中的垃圾回收机制会对堆里一些不再被引用的对象进行垃圾回收;还有一些Java代码不方便实现的功能必须调用底层操作系统的功能,所以Java需要跟底层操作系统的一些功能打交道就要用到本地方法接口来调用操作系统提供的一些功能方法
程序计数器

指令前面的数字可以理解成一个指令对应的内存地址,当这些指令被加载到JVM内存以后,就会有这些地址信息,根据地址信息就可以找到这条命令来执行它,加上程序计数器后执行流程:拿到getstatic指令通过解释器解释为机器码再交给CPU来运行,与此同时它也会把它下一条的指令astore_1的地址3放入程序计数器,等第一条指令执行完后,解释器就会到程序计数器里根据地址找到下一条指令来执行,以此类推,所以如果没有这个程序计数器,解释器就不知道接下来要执行哪条指令

程序计数器是在JVM规范中唯一一个不会出现内存溢出的区域,其他区域都会出现内存溢出
虚拟机栈



如果方法内局部变量没有逃离方法的作用访问,它是线程安全的,反之,你的局部变量当成返回值返回,就会存在线程安全的风险,你必须对它施加保护;
如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全,如果是基本类型变量也可以保证线程安全
栈内存溢出

适当减小栈的总大小,递归次数变小

两个类之间的循环引用导致的栈内存溢出(第三方类库产生的无限递归)
线程诊断 - CPU占用高

在Linux中,tid是指线程ID(Thread ID),它是用来唯一标识一个线程的数字。每个线程都有自己的tid,可以通过系统调用或命令来获取线程的tid。tid的作用是在多线程程序中区分不同的线程,以便操作系统能够正确地管理和调度线程。
在Linux中,nid是指内核线程ID(Kernel Thread ID),它是用来唯一标识一个内核线程的数字。与用户线程ID(tid)不同,nid是用来标识内核线程的。内核线程是在内核空间运行的线程,通常用于执行系统任务和管理内核资源。nid的作用是在内核中区分不同的线程,以便内核能够正确地管理和调度内核线程。
线程诊断 - 死锁

本地方法栈

堆
本地方法:在一些Java的基础类库里、在执行引擎中都会去调用这些本地方法(native)
线程私有:JVM栈、程序计数器、本地方法栈
线程共享:方法区、堆

案例
- 垃圾回收后,内存占用仍然很高
原始方法:jps获取java进程→jmap -heap pid(观察Eden Space占用内存(used)和老年代占用内存之和)→jconsole(执行GC发现堆内存并没有减多少)→jvisualvm(监视界面点击堆dump→查找最大对象并观察最大对象内部情况定位问题)
方法区
方法区跟堆一样都是线程共享的区域,里面存储了跟类结构相关的信息,如成员变量(字段),方法数据,成员方法以及构造器方法的代码部分,包括特殊方法(类的构造器),还有一个运行时常量池
概念上定义了方法区,但具体的不同JVM厂商去实现并不会去遵循JVM逻辑上的定义,逻辑上方法区是堆的一部分,但具体实现它根据不同的JVM厂商都会有所不同
Oracle的Hotspot虚拟机它在JDK8以前它的实现叫做永久代,它就是根据堆的一部分来实现的方法区,但到了1.8以后,永久代就移除了,换了一种实现,这个实现叫做元空间,它就不是用的堆的内存了,它用的是本地内存(操作系统内存),所以不同的实现它的方法区的位置就会有所不同,如IBM J9,之前BEA公司的JRockit JVM(JRockit JVM最初是由一家名为BEA Systems的公司开发的。后来,BEA Systems被Oracle收购,JRockit JVM也随之成为Oracle的产品。)都没有把方法区放在堆内存中,因此当提到永久代时,那它只是Hotspot JDK在8以前的一个实现而已,方法区是规范,永久代和元空间都是它的一种实现
Method Area
The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. The method area is analogous to the storage area for compiled code of a conventional language or analogous to the "text" segment in an operating system process. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization.
The method area is created on virtual machine start-up. Although the method area is logically part of the heap, simple implementations may choose not to either garbage collect or compact it. This specification does not mandate the location of the method area or the policies used to manage compiled code. The method area may be of a fixed size or may be expanded as required by the computation and may be contracted if a larger method area becomes unnecessary. The memory for the method area does not need to be contiguous.
A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of the method area, as well as, in the case of a varying-size method area, control over the maximum and minimum method area size.
The following exceptional condition is associated with the method area:
- If memory in the method area cannot be made available to satisfy an allocation request, the Java Virtual Machine throws an
OutOfMemoryError.Java 虚拟机有一个方法区,由所有 Java 虚拟机线程共享。方法区类似于传统语言编译代码的存储区,或类似于操作系统进程中的"文本"段。它按类存储结构,如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括用于类和实例初始化以及接口初始化的特殊方法(第 2.9 节)。
方法区在虚拟机启动时创建。尽管方法区在逻辑上是堆的一部分,但简单的实现可能会选择不对其进行垃圾回收或压缩。本规范并未规定方法区的位置或用于管理编译代码的策略。方法区的大小可以是固定的,也可以根据计算需要进行扩展,如果不需要更大的方法区,也可以进行收缩。方法区的内存不需要连续。
Java 虚拟机实现可为程序员或用户提供对方法区初始大小的控制,在方法区大小可变的情况下,还可提供对最大和最小方法区大小的控制。
以下例外条件与方法区域相关联:
- 如果方法区中的内存无法满足分配请求,Java 虚拟机将抛出 OutOfMemoryError(内存不足错误)。

元空间在用的是本地内存(物理内存),很难暴露出问题,因此设置较小的元空间最大值把OOM问题暴露出来



jdk 中的 ClassWriter(asm项目)和 CGLIB 中的 ClassWriter 都继承了 ClassVisitor,虽然 ClassVisitor 包名不同,但都是一个东西,即在运行期间动态生成类的字节码完成动态的类加载…
运行时常量池
Java 代码编译为二进制字节码(类基本信息,常量池,类方法定义,包含了虚拟机指令)
类方法定义(反编译)
{
public cn.itcast.jvm.t5.HelloWorld(); # 无参构造
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 4: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcn/itcast/jvm/t5/HelloWorld;
public static void main(java.lang.String[]); # main方法
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
# 获取System类的静态变量out
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
# 加载hello world字符串参数
3: ldc #3 // String hello world
# 执行一次虚方法调用,即静态变量out中的println方法
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
# 整个main方法执行结束
8: return
LineNumberTable:
line 6: 0
line 7: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "HelloWorld.java"指令后的注释是javap程序加上的,这些指令真正由解释器去执行翻译的时候是没有的,解释器看到的只有
getstatic #2
ldc #3
invokevirtual #4解释器在解释的过程中就会根据#2,#3,#4进行一个查表翻译,如执行getstatic,根据后面的#2到常量池中查找
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // hello world
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // cn/itcast/jvm/t5/HelloWorld
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcn/itcast/jvm/t5/HelloWorld;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 HelloWorld.java
#20 = NameAndType #7:#8 // "<init>":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 hello world
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 cn/itcast/jvm/t5/HelloWorld
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V#2 = Fieldref:引用了一个成员变量,又根据其后面的#21.#22找到#28中是System类和#29中的变量out与#30中out变量的类型PrintStream;
执行ldc,根据后面的#3到常量池中查找
#3 = String:指一个字符串,根据后面#23 = Utf8,Utf8是JVM常量池中的引用类型,找到一个hello world字符串,把这个hello world符号变成一个字符串对象作为参数加载进来;
执行invokevirtual,根据后面#4到常量池中查找
#4 = Methodref:指一个方法引用,根据后面 #24.#25找到java/io/PrintStream类中的方法println:(Ljava/lang/String;)V,方法参数为字符串类型,无返回值;
因此常量池的作用就是给这些指令提供一些常量符号,根据常量符号以查表的方式找到具体的值,这样虚拟指令才能够成功的执行
上面的Constant pool只是一个类的常量池信息,运行时需要把它放到内存中,常量池放到内存中的位置被称为运行时常量池,并把里面的符号地址变为真实地址,上面的1,2,3就会变为真正的地址,根据内存地址找到相应的符号,类名,方法名等
StringTable
StringTable数据结构上是一个hash表,俗称串池,刚开始里面是空的,执行指令把符号变为字符串("a")对象后作为key到串池中查找有没有取值相同的key,没有就直接放入到串池中,这样串池就有了这个字符串对象了
每个字符串对象并不是事先就放入串池中,而是执行时用到当行代码才开始创建这个字符串对象(懒惰),串池中的对象只会存一份,取值相同的字符串是唯一的(缓存效果)
# String s1 = "a";
# String s2 = "b";
# String s3 = "ab";
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
...
LocalVariableTable:
Start Length Slot Name Signature
0 51 0 args [Ljava/lang/String;
3 48 1 s1 Ljava/lang/String;
6 45 2 s2 Ljava/lang/String;
9 42 3 s3 Ljava/lang/String;字符串变量拼接
# String s1 = "a";
# String s2 = "b";
# String s3 = "ab";
# String s4 = s1 + s2;//通过以下分析s3的值在运行时常量池中,而s4引用了新的字符串对象"ab",在堆中,两者的位置不一样(new StringBuilder().append("a").append("b").toString())
0: ldc #2 // String a
2: astore_1 #//把局部变量s1存入局部变量表中的槽位1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder 创建StringBuilder对象
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V 调用StringBuilder的<init>方法(构造方法,()为无参)
16: aload_1 #//把s1即"a"加载进来,从局部变量表中槽位1拿到s1变量
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 调用StringBuilder的append方法:append("a")
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; append("b")
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 调用StringBuilder的toString方法:
27: astore 4 #//把toString方法转换后的结果(new String("ab"))存入局部变量表的槽位4
...
LocalVariableTable:
Start Length Slot Name Signature
0 51 0 args [Ljava/lang/String;
3 48 1 s1 Ljava/lang/String;
6 45 2 s2 Ljava/lang/String;
9 42 3 s3 Ljava/lang/String;
29 22 4 s4 Ljava/lang/String;@Override
public String toString() {
// Create a copy, don't share the array 把StringBuilder当前拼接好的值又创建了一个新的String对象
return new String(value, 0, count);
}编译期优化
# String s1 = "a";
# String s2 = "b";
# String s3 = "ab";
# String s4 = s1 + s2;
# String s5 = "a" + "b";
...
6: ldc #4 // String ab
8: astore_3
...
29: ldc #4 // String ab 执行到此行指令会通过key为"ab"在串池中查找符号,会发现已经有此符号,就不会去创建新的字符串对象了,就会沿用串池中已有的对象。这个操作是javac在编译期的优化,因为a和b都是常量,它们的内容都不会变,它俩拼接的结果是确定的,既然是确定的,那在编译期间就可以知道它们拼接的结果肯定是ab而不是别的值。s4与s5不同,s1和s2是变量,变量将来在运行期间其引用有可能被修改,既然它的结果有可能发生修改,那它的结果就不能确定,所以它必须在运行期间用StringBuilder的方法动态地拼接,而s5在编译期就能确定结果就不用通过StringBuilder的方式拼接
31: astore 5
...
LocalVariableTable:
Start Length Slot Name Signature
0 51 0 args [Ljava/lang/String;
3 48 1 s1 Ljava/lang/String;
6 45 2 s2 Ljava/lang/String;
9 42 3 s3 Ljava/lang/String;
29 22 4 s4 Ljava/lang/String;
33 18 5 s5 Ljava/lang/String;字符串延迟加载

intern与面试题
//["a", "b"]
String s = new String("a") + new String("b");//new String("ab")
//["a", "b", "ab"]
// 堆 new String("a") new String("b") new String("ab")
String s2 = s.intern(); // 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回(jdk1.7,1.8)
//s2 == "ab" s已放入串池 s == "ab"
System.out.println( s2 == "ab");//true
System.out.println( s == "ab" );//true jdk1.6会拷贝一份到常量池,s != "ab" s = new String("ab")
//--------------------------------------
String x = "ab";
//["ab", "a", "b"]
String s = new String("a") + new String("b");//new String("ab")
// 堆 new String("a") new String("b") new String("ab")
//因为串池中已有"ab",所以s并没有将堆中"ab"对象放入到串池,只是直接返回串池已有的"ab"(jdk1.7,1.8),s -> new String("ab")
String s2 = s.intern();
//s2 == "ab" 串池已有"ab",s = new String("ab")
//以下两个结果1.8、1.6是一样的
System.out.println( s2 == x);//true
System.out.println( s == x );//falseStringBuilder动态拼接的新字符串对象不会加入到串池中,可以调用String的intern方法
- 1.8将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回
- 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,放入串池, 会把串池中的对象返回
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";//ab
String s4 = s1 + s2;//new String("ab")
String s5 = "ab";
String s6 = s4.intern();//ab
System.out.println(s3 == s4);//false
System.out.println(s3 == s5);//true
System.out.println(s3 == s6);//true
//-----------------------1.8
String x2 = new String("c") + new String("d"); // new String("cd")
String x1 = "cd";
x2.intern();//x2=new String("cd")
// 问,如果调换了【↑最后两行代码】的位置呢
System.out.println(x1 == x2);//false
//^^^^^^^^^^^^^^^^^^^^^^^^^
String x2 = new String("c") + new String("d"); // new String("cd")
x2.intern();// cd
String x1 = "cd";
System.out.println(x1 == x2);//true
//------------------------1.6
String x2 = new String("c") + new String("d"); // new String("cd")
String x1 = "cd";
x2.intern();//x2=new String("cd")
// 问,如果调换了【↑最后两行代码】的位置呢
System.out.println(x1 == x2);//false
//^^^^^^^^^^^^^^^^^^^^^^^^^
String x2 = new String("c") + new String("d"); // new String("cd")
x2.intern();// cd x2=new String("cd")
String x1 = "cd";
System.out.println(x1 == x2);//false位置

JVM1.6中,StringTable它是常量池的一部分,它随常量池存储在永久代中;从1.7开始,它就把StringTable转移到堆中,即1.7,1.8把StringTable从永久代移到堆里,这是因为永久代的内存回收效率很低,永久代是需要Full GC时才会触发永久代的垃圾回收,而Full GC得等到整个老年代的空间不足时才会触发,这个触发的时间就有点晚,间接导致StringTable的回收效率并不高,其实StringTable的使用非常频繁,它里面都存储着字符串常量,一个Java的应用程序中大量的字符串常量对象都会分配到StringTable里,如果StringTable的回收效率不高就会占用大量的内存,进而导致永久代内存不足,因此1.7开始就将StringTable转移到堆里,在堆中的StringTable只需minor gc就会触发其垃圾回收,把常量池中用不到的字符串常量回收掉,这样就大大减轻了字符串对堆内存的占用



如果进行垃圾回收时花费了98%的时间用来进行垃圾回收并且仅回收了不到2%的垃圾就会抛出OOM,这种情况属于癌症晚期了,即JVM进入了一个不可救药的地步了,因为内部花费了98%的精力去救JVM,但只回收了不到2%的堆内存,这时就不会进行垃圾回收了,就会报以上错误,就不是报堆空间不足的错误
把这个限制关掉后就会报堆空间不足

垃圾回收
StringTable会受到垃圾回收的管理的,当内存空间不足时,StringTable中那些没有被引用的字符串常量仍然会被垃圾回收
-XX:+PrintStringTableStatistics是打印字符串表的统计信息,通过它我们可以清除的看到常量池中字符串实例的个数,包括一些占用大小等信息
-XX:+PrintGCDetails -verbose:gc这两个参数是打印垃圾回收的详细信息,如果发生垃圾回收,它就会把垃圾回收的次数,花费时间等显示出来
StringTable的统计信息,StringTable底层类型于Hashtable的实现,即哈希表,数组+链表的结构,每个数组的个数称为桶,StringTable就是以哈希表的方式来存储数据的

上面代码没有任何字符串操作就已经有1754个字符串对象了,这是因为Java程序在运行时类名、方法名等数据也是以字符串常量的形式表示的,它们也存储在串池中,加入100个字符串对象如下:

加入10000个字符串对象如下:

性能调优
调整StringTable桶个数
StringTable底层是一个哈希表,哈希表的性能是跟它的大小密切相关的,如果哈希表的桶个数比较多,那么相对的元素就会比较分散,哈希碰撞的几率就会减少,查找的速度也会变快,反之,如果桶的个数比较少,那么哈希碰撞的几率就变高,导致链表较长从而查找速度就会受到影响
调整 -XX:StringTableSize=桶个数,设置StringTable的大小范围为1009到2305843009213693951


如果你的系统里字符串的常量个数非常多的话,建议适当把StringTable的桶个数设置调整的较大些,让它有个较好哈希分布,较少哈希冲突(以空间换时间),就可以让StringTable中串池的效率得到明显的性能提升
intern入池


如果你的应用里有大量的字符串并且这些字符串有可能存在重复的情况,就可以让字符串入池来减少字符串个数节约我们堆内存的使用
直接内存
定义与使用
直接内存是属于系统内存,是我们操作系统的内存
Direct Memory
- 常见于 NIO 操作时,用于数据缓冲区(NIO里有一个ByteBuffer,它使用和所分配的内存就是直接内存,它不属于JVM来管理而属于操作系统内存)
- 分配回收成本较高,但读写性能高(操作系统内存,Java要想直接使用或分配或将来释放掉就会比较慢些,但读写性能高)
/**
* 演示 ByteBuffer 作用
*/
public class Demo1_9 {
static final String FROM = "E:\\编程资料\\第三方教学视频\\youtube\\Getting Started with Spring Boot-sbPSjI4tt10.mp4";//800多M文件
static final String TO = "E:\\a.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);
}
} Java本身并不具备磁盘读写的能力,它要调用磁盘读写功能需要调用操作系统提供的函数(native),切换到内核态以后,就可以由CPU函数真正去读取具体的磁盘文件内容,从磁盘文件读取进来,读取进来后内核态会在操作系统内存中划分一块系统缓冲区,那么磁盘文件内容就会先读入这个缓冲区,它不会把几百兆的内容一次性读取到内存,这样内存就太紧张了,所以让文件内容到缓冲区分次读取。但是注意,系统缓冲区Java代码是不能够运行的,Java会在堆内存中分配一块Java缓冲区,对应代码中的new Byte[],Byte数组大小化为1MB,Java代码要能访问刚才读取的那个流中的数据,必须再从系统缓冲区把这个数据间接地给它读入Java缓冲区,之后就再次进入用户态,再去调用输出流的写入操作,反复进行读写操作,把整个文件复制到目标位置。因为有两块缓冲区,系统内存有一块缓冲区,Java里也有一块缓冲区,那你读取的时候必然设计到你的数据得存两份,第一次读到系统缓冲区还不行,因为Java代码访问不到它们,所以要把系统缓冲区的数据再读到Java缓冲区,这样就造成不必要的复制了,效率不是很高。
使用Direct Memory情况:调用ByteBuffer的allocateDirect静态方法分配直接内存,磁盘文件读取时会进入直接内存,而Java代码也可以访问到直接内存,这样就比刚才没用直接内存的代码少了一次缓冲区的复制操作,速度就得到成倍的提升,这就是直接内存带来的好处,它也是擅长或适合做这种文件的IO操作,当然在NIO里也会再进一步优化

- 不受 JVM 内存回收管理(JVM进行垃圾回收时不会直接去释放直接内存它分配的内存)
内存溢出
/**
* 演示直接内存溢出
*/
public class Demo1_10 {
static int _100Mb = 1024 * 1024 * 100;
public static void main(String[] args) {
List<ByteBuffer> list = new ArrayList<>();
int i = 0;
try {
while (true) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
list.add(byteBuffer);//让byteBuffer长时间占用内存而放入生命周期长的集合中观察异常暴露
i++;
}
} finally {
System.out.println(i);
}
// 方法区是jvm规范, jdk6 中对方法区的实现称为永久代
// jdk8 对方法区的实现称为元空间
}
}抛出直接缓冲内存溢出

分配和回收原理
- 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
以下代码看似好像是垃圾回收把直接内存给回收的,其实并非如此
/**
* 禁用显式回收对直接内存的影响
*/
public class Demo1_26 {
static int _1Gb = 1024 * 1024 * 1024;
/*
* -XX:+DisableExplicitGC 显式的
*/
public static void main(String[] args) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
System.out.println("分配完毕...");
System.in.read();
System.out.println("开始释放...");
byteBuffer = null;
System.gc(); // 显式的垃圾回收,Full GC
System.in.read();
}
}以下代码看出ByteBuffer底层分配和释放内存的相关的类型是Java中非常底层的Unsafe类,这个类可以干一些分配与释放内存的事情,但不推荐新手使用此类来操作,它都是JDK内部去使用的
/**
* 直接内存分配的底层原理:Unsafe
*/
public class Demo1_27 {
static int _1Gb = 1024 * 1024 * 1024;
public static void main(String[] args) throws IOException {
Unsafe unsafe = getUnsafe();
// 分配内存 返回的long类型变量代表直接内存分配的一个内存地址,将来可以通过这个内存地址调用freeMemory方法释放刚才分配的内存
long base = unsafe.allocateMemory(_1Gb);
unsafe.setMemory(base, _1Gb, (byte) 0);
System.in.read();
// 释放内存
unsafe.freeMemory(base);
System.in.read();
}
public static Unsafe getUnsafe() {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
return unsafe;
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}运行程序观察任务管理器Java进程内存占用变化,最终内存被释放由此验证直接内存的分配和释放是通过Unsafe对象来管理的,不是JVM的垃圾回收,垃圾回收只能释放Java的内存,垃圾回收针对Java中那些无用的对象,它们的释放是自动的,无需手动调用任何方法,但直接内存不同,它必须主动去调用Unsafe的freeMemory方法才能完成对这个内存的释放
直接内存的释放是借助了Java中的一个虚引用机制
- ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程(守护)通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
//...↓
// Primary constructor
//
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
//调用的allocateDirect其内部间接地调用了Unsafe的allocateMemory方法
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
//完成了对直接内存的一个分配
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
//直接内存释放的关键点 回调的任务对象
//Cleaner在Java类库中是一个特殊的类型,即虚引用类型,它的特点是当它所关联的对象(ByteBuffer)被回收时,那么Cleaner就会触发这个虚引用的clean方法,ByteBuffer是Java对象,还是受垃圾回收管理,当ByteBuffer自己被垃圾回收时,就会触发虚引用对象(Cleaner)中的clean方法
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
//....
private static class Deallocator
implements Runnable
{
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
// Paranoia
return;
}
//对分配的直接内存进行主动释放
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
public class Cleaner extends PhantomReference<Object> {
private final Runnable thunk;
...
//此方法不是在主线程执行的,由后台ReferenceHandler的线程专门去监测这些虚引用对象,一旦虚引用对象关联的实际对象,也就是DirectByteBuffer对象被回收掉以后,就会调用虚引用对象(Cleaner)中的clean方法,然后执行任务对象的run方法(Deallocator对象中的run方法执行直接内存的释放)
public void clean() {
if (remove(this)) {
try {
this.thunk.run();
} catch (final Throwable var2) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null) {
(new Error("Cleaner terminated abnormally", var2)).printStackTrace();
}
System.exit(1);
return null;
}
});
}
}
}
...
}-XX:+DisableExplicitGC参数表示禁用显式的垃圾回收,就是让我们代码中的System.gc();无效,这行代码指显式地进行垃圾回收,是一种Full GC,是一种影响性能的垃圾回收,不光要回收新生代还要回收老年代,会造成程序暂停的时间比较长(STW),为了防止在程序中写System.gc();代码而触发这种显式的垃圾回收,就可以加上这个VM参数进行JVM调优。
加上这个参数以后可能又会影响直接内存的回收机制,测试发现,虽然对别的代码没有影响,但对这个直接内存还是有影响的,因为我们不通过这种显式的垃圾回收代码来回收这个ByteBuffer对象,那只能等到真正的垃圾回收时才会被清理,那么它关联的直接内存才会被释放掉,所以就造成了直接内存就会占用较大,长时间得不到释放,那只能通过Unsafe的freeMemory方法来主动释放了,这个参数开了ByteBuffer对象得不到释放,关了只能显式垃圾回收ByteBuffer对象
TLAB(Thread-Local Allocation Buffer)和ThreadLocal 是两个不同的概念,它们在虚拟机中的作用和功能也不同。
TLAB 是虚拟机为每个线程分配的一块私有的内存区域,用于存储线程私有的对象实例。它的作用是减少线程间的竞争,提高对象分配的效率。
ThreadLocal 是 Java 中的一个类,用于在每个线程中存储和访问线程私有的变量。它提供了一种线程局部变量的机制,使得每个线程都可以拥有自己的变量副本,而不会相互干扰。
虚拟机中的TLAB和ThreadLocal没有直接的关系,它们分别用于不同的场景和目的。TLAB用于优化对象的分配和内存的管理,而ThreadLocal用于实现线程私有的变量存储。在某些情况下,可以将ThreadLocal变量存储的对象实例放在TLAB中,以达到更好的性能。
垃圾回收
如何判断对象可以回收
引用计数法

弊端:循环引用。
A对象引用了B对象,A对象引用计数为1,而B对象引用了A对象,B对象引用计数也为1,并且这两个对象没有谁引用它俩,这种情况是不能被垃圾回收的,它们的引用计数都为1,虽然都不会被引用了,但是它们的引用计数不能归零,导致这两个对象不能够被垃圾回收,就造成内存泄漏问题。早期Python虚拟机进行垃圾回收控制时就是采用了引用计数法,JVM就没有采用这种方法,而是采用了可达性分析算法。
可达性分析算法
可达性分析算法首先要确定一系列根对象,根对象可以理解为那些肯定不能当成垃圾被回收的对象。在垃圾回收之前,首先对堆内存中的所有对象进行一遍扫描,然后看看每个对象是不是被刚才提到的根对象所直接或间接地引用,如果是这个对象就不能被回收,反之,如果一个对象没有被根对象直接或间接地引用,那么这个对象就可以作为垃圾,将来可以被回收。比如一串葡萄,把葡萄洗干净后提着它的根,根上的葡萄不能回收,摘下来的葡萄就可以被回收
- Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象
- 扫描堆中的对象,看是否能够沿着 GC Root对象(一系列对象) 为起点的引用链找到该对象,找不到,表示可以回收
使用MemoryAnalyzer抓取GC Root快照
- jps获取Java进程id
jmap -dump:format=b,live,file=文件名 进程id

Native Stack:JVM在执行一些方法调用时它必须调用操作系统方法,操作系统方法在执行时它所引用的一些Java对象也是可以作为根对象,也是不能被垃圾回收的

Thread:活动线程,活动线程中所使用的对象肯定不能被垃圾回收,线程正在运行,把它所引用的对象给回收了就没法继续运行,线程运行时由一次次的方法调用组成,每次方法调用都会产生一个栈帧,栈帧内所使用的一些“东西”可以作为根对象




Busy Monitor:Java对象中的同步锁机制,即synchronized关键字,对一个对象加了同步锁,加了锁的对象就不能被垃圾回收,如果它将来被垃圾回收了,谁来解锁呢,对象跟锁都被回收掉了,所以这种加了锁的对象也是可以作为根对象的,它们所引用的其他对象也是需要被保留的

四种引用
- 强引用
- 只要沿着GC Root引用链能够找到该对象,那么该对象就不会被垃圾回收
- 只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收


- 软引用(SoftReference)
- 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象
- 可以配合引用队列来释放软引用自身
- 弱引用(WeakReference)
- 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
- 可以配合引用队列来释放弱引用自身

软引用与弱引用自身也占用一定内存,如果对它俩占用的内存进一步释放,需使用引用队列来找到它俩,它俩还可能被强引用着,所以在引用队列里依次遍历把它俩占用的内存释放掉
- 虚引用(PhantomReference)
- 必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
- 终结器引用(FinalReference)
- 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize方法,第二次 GC 时才能回收被引用对象

虚引用对象被回收时,虚引用对象自己就会放入引用队列,从而间接地通过ReferenceHandler线程来调用虚引用对象的方法,然后调用Unsafe.freeMemory方法来释放直接内存

不使用finalize方法释放内存的理由
finalize方法工作效率很低,第一次回收时还不能真正给回收掉,而是先把它入队,而且处理引用队列的线程优先级很低,被执行的机会很少,就会导致该对象的finalize方法迟迟不会被调用,该对象占用的内存也迟迟得不到释放;
场景
软引用

- 引用队列清理软引用本身
/**
* 演示软引用, 配合引用队列
*/
public class Demo2_4 {
private static final int _4MB = 4 * 1024 * 1024;
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[]> ref = new SoftReference<>(new byte[_4MB], queue);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
// 从队列中获取无用的 软引用对象,并移除
Reference<? extends byte[]> poll = queue.poll();
while( poll != null) {
list.remove(poll);
poll = queue.poll();
}
System.out.println("===========================");
for (SoftReference<byte[]> reference : list) {
System.out.println(reference.get());
}
}
}弱引用
- 弱引用一般都是在垃圾回收时就会把一些弱引用对象所占用的内存释放掉,弱引用对象自身占用的内存的释放同样要配合引用队列来实现,具体做法跟软引用非常类似

垃圾回收算法
标记清除(Mark Sweep)

内存释放并不会把标记的区域作清零处理,它只需要把对象所占用的内存的起始和结束的地址给记录下来放到一个叫做空闲的地址列表里即可,下次再分配新对象时就到这个空闲的地址列表里找看有没有一块足够的空间容纳这个新对象,如果有就进行一个内存分配,并不会把此区域作清零处理
优点:速度快,清除操作只需要把垃圾对象的内存的起始和结束地址做一个记录就完成了清除操作,无需再做额外的处理,整个垃圾回收的速度相对比较快
缺点:容易产生内存碎片,在清除操作完成后,不会对这个空闲的内存空间再做进一步的整理工作了,所以如果分配了一个较大的对象,如数组,数组对象的内存分配需要一段连续空间,就很难塞到比较窄的空间了,总的空闲空间加起来是够的,但是由于空闲空间不连续,这种情况称为内存碎片,造成新对象仍然不能被提供一个有效内存使用,就造成了一个内存溢出问题
标记整理(Mark Compact)

标记阶段跟标记清除算法的标记阶段是一样的,也是对堆中这些对象进行标记,看看哪些对象是垃圾,那些没有被GC Root引用的对象就会作为垃圾,区别是在第二阶段是整理阶段而不是清除阶段, 所谓的整理就是避免在标记清除算法产生的内存碎片问题,在清除垃圾的过程中,它会把可用的对象向前移动,让内存更为紧凑,整理之后,垃圾就会被清除,那连续的空间就更多了,就不会造成标记清除时产生的内存碎片问题
优点:没有内存碎片
缺点:整理阶段就会牵扯到对象的移动,效率就会偏低,速度较慢(图中红字)
复制(Copy)

复制算法比较特殊,它是把内存区域划分为大小相等的两块区域,左边区域称为FROM,右边为TO,TO区域始终空闲着,里面一个对象都没有。首先还是先做标记,找到那些不被GC Root引用的对象标记为垃圾,然后把FROM区域中那些尚存活的对象给它们复制到TO区域中,复制完成后FROM区域就全都是垃圾了,就可以一下子清空了,并且交换FROM和TO的位置,即原来的FROM变为TO,原来的TO变为FROM
优点:不会产生内存碎片
缺点:会占用双倍的内存空间
总结
这三种算法实际在JVM的垃圾回收机制中,都会根据不同的情况来采用,不会只有其中一种算法,它会结合其中多种算法来共同实现这个垃圾回收的,就是接下来的分代垃圾回收机制
分代垃圾回收

分代垃圾回收机制把整个堆内存(大的区域)划分为两块,一个叫新生代,一个叫老年代,新生代又进一步划分为三个小的区域,分别叫伊甸园、幸存区From和幸存区To,这么划分的原因主要是在Java中有的对象可能需要长时间使用,长时间使用的对象就把它放到老年代中,而那些用完就可以丢弃的对象就放到新生代中,这样的话就可以针对这个对象生命周期的不同特点进行不同的垃圾回收策略,老年代的垃圾回收很久才发生一次,而新生代的垃圾回收就发生的比较频繁,新生代处理的都是那些朝生夕死的对象,而老年代处理的都是那些更有价值而长时间存活的,这样针对不同的区域采用不同的算法就可以更有效地对垃圾回收进行一个管理。
打个比方,一栋居民楼(Java中的堆内存),居民楼中每家每户每天都要产生一些垃圾,这个垃圾信息我们需要一个保洁工人来进行一个处理,如果每家每户都打扫显然效率和时间不可接受,在现实生活中都会在楼下设一个专门丢弃垃圾的垃圾场(新生代),垃圾场的垃圾就是那些生命周期更短的垃圾,如盒饭、纸巾等,保洁工人只需每天打扫一次即可(Minor GC),每家每户存储的垃圾(老年代),如用久的椅子不想扔就暂存在家里,将来等到家里空间放不下了,就叫保洁工人来一次大清理(Full GC),把那些无用的垃圾清理掉,耗时就比较长了,当然执行的频率也比较低,因为垃圾场每天清理一次就够了,而每家每户的垃圾相对更有价值一些,需等到整个空间不足时才去清理。
分代垃圾回收机制
- Minor GC:
当我们创建一个新的对象时,那么这个新的对象默认情况就会采用伊甸园的一块空间(伊甸园就是人类始祖亚当夏娃诞生的地方,那么我们的对象也是诞生在伊甸园中),接下来会有更多的对象被创建,它们都被分配到这个伊甸园当中,伊甸园逐渐地被占满了,当我们再创建一个对象时,这个伊甸园空间就不够了,容纳不下了,这个时候就会触发一次新生代的Minor GC,Minor GC触发以后就会采用可达性分析算法沿着GC Root引用链去找看哪些对象是有用还是可以作为垃圾,先进行一次标记动作,标记成功了就采用复制算法把存活的对象复制到幸存区To中,存活对象复制到To区域后会让其寿命加1,刚开始寿命是0,现在经历了一次垃圾回收还幸存下来不死它们的寿命就加1,伊甸园的对象就可以回收掉了,做完复制操作以后同样将幸存区To与幸存区From的位置交换。
第一次垃圾回收后,空间就充足了,可以向伊甸园分配一些对象,刚才放不下的对象就放进去了,类似的又分配一大堆对象进去,等伊甸园又满了就触发第二次Minor GC,第二次垃圾回收除了要找到伊甸园中存活的对象以外,还要到幸存区中看看有没有需要继续存活的对象,幸存区中也一样,它也不是第一次经过了垃圾回收不死,第二次仍然存活的,也许第二次回收时它已经没用了,这个时候就会把第二次垃圾回收时幸存的对象复制到幸存区To里,寿命加1,如果幸存区From中有对象也存活了,也复制到幸存区To中,寿命加1变为2,然后把伊甸园中的对象清理掉,和幸存区From没用的对象也清理掉,同样两个幸存区的位置交换,这样新对象就可以继续放入伊甸园了。当然,幸存区中的对象不会永远在幸存区中,当它的寿命超过了一个阈值,即经历了15次垃圾回收后仍活着,说明这个对象价值较高,经常在使用,就没必要一直将它留在幸存区中了,因为它还在幸存区中留着,以后再进行垃圾回收时还是不能回收它,所以这个对象的寿命如果超过一个阈值15了,就把它晋升到老年代去,因为老年代垃圾回收频率比较低,不会轻易把它回收掉,像这种价值较高的对象就从幸存区中把它晋升到老年代去。
minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
- Full GC:
将来老年代晋升的对象多了,也把老年代空间占满呢?有这么一种情况,新生代放的对象也挺多了,老年代放的对象也挺多了,几乎全满,这个时候会一个新的对象往新生代的伊甸园区放不下了,From区域也放不下了,老年代也放不下了,这个时候就会触发一次Full GC,这个垃圾回收动作都是在空间不足时才会触发的。Full GC就是当老年代的空间不足,当然会长时间回收新生代内存,当新生代内存不够时就会尝试触发这个Full GC来触发这个老年代的垃圾回收,老年代的垃圾回收就会做一次整个清理,从新生代到老年代

stop the world:在发生垃圾回收时,必须暂停其他的用户线程,由垃圾回收线程(守护)来完成垃圾回收的动作,当把这些要处理的对象都从伊甸园和幸存区From拷贝到幸存区To以后,垃圾回收的动作都做完了,其他的用户线程才能继续运行。这样做的原因是在垃圾回收的过程中会牵扯到对象的复制,对象的地址会发生改变,这种情况下如果多个线程都在同时运行,这样就造成了一个混乱,对象都移动了,其他线程再访问这个对象地址根据原来的地址就找不到了,因此minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行,minor gc暂停的时间非常的短,因为新生代本身大部分对象都是垃圾,都会被回收掉,复制的对象只有很少一部分,所以标记和复制的暂停时间并不长
当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(4bit),因为寿命是保存在每个对象的对象头中,其中存寿命的部分是4bit(0000 ~ 1111),不设得更大的原因是1、没有意义的;2、对象头比较精贵,每个bit都有各自的用途,不能把所有的空间都留给寿命存储。不同的垃圾回收器阈值也不一样,有的时候当空间紧张时,也许对象寿命还没有到15,当幸存区空间紧张时对象也会提前晋升到老年代去
当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gc,STW的时间更长,因为老年代采用的回收算法跟新生代是不一样的,新生代是一种复制算法,老年代因为存活的对象比较多,整理起来或清除起来比较慢,另外,采用的算法可能是标记+清除或标记+整理,标记+清除就好点,速度比较快,标记+整理速度就比较慢,再者,老年代的对象都不是那么容易被当成垃圾回收,所以回收的效率相对的更低,回收时间相对也更长,STW时间也会更长,full gc结束后若老年代空间仍然不足,内存分配失败就会触发OutOfMemory:java heap space
相关 VM 参数
| 含义 | 参数 |
|---|---|
| 堆初始大小 | -Xms |
| 堆最大大小 | -Xmx 或 -XX:MaxHeapSize=size |
| 新生代大小 | -Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size ) |
| 幸存区比例(动态) | -XX:InitialSurvivorRatio=ratio (初始化幸存区比例)和 -XX:+UseAdaptiveSizePolicy(开启动态的开关,开启后就会动态调整新生代中伊甸园和幸存区的比例) |
| 幸存区比例 | -XX:SurvivorRatio=ratio(默认值是8,新生代假设为10M内存,其中8M是划给伊甸园的,剩下2M是二等份,一份给From,一份给To) |
| 晋升阈值 | -XX:MaxTenuringThreshold=threshold(调整新生代中对象的晋升阈值,默认值跟垃圾回收器有关,有的是15,有的是6) |
| 晋升详情 | -XX:+PrintTenuringDistribution |
| GC详情 | -XX:+PrintGCDetails -verbose:gc |
| FullGC 前 MinorGC | -XX:+ScavengeBeforeFullGC(FullGC前是否要做一次MinorGC,减少一些不必要的对象,这样会加速FullGC的进度,默认是打开的) |
幸存区比例还有一种是动态调整的,在某些垃圾回收器下,需要动态调整幸存区的比例,也就是不用我们自己去控制它到底是8-1-1还是6-2-2
案例分析
下图是在指定JVM参数下运行没有任何代码、加入不同大小的对象的main方法的堆内存的占用情况
- 0行代码

- 加入7M的对象

7M + 512k

7M + 512k*2

- 8M

8M*2

- 子线程下加入
8M*2抛出的OOM不会导致主线程结束(线程对象也占用一定内存)


线程和线程之间是隔离的,但是堆是共享的,子线程放入第二个8mb的对象时,报错死亡,但是主线程后续并没有继续放入新的对象;
子线程内存溢出抛出的异常跟所有其他的异常其实是一样的,所以不会让主线程停止运行,而主线程的堆状态是跟子线程抛出异常时相同的,因为它们共用堆内存,看程序运行结束的堆状态信息也能体现这一点;
堆是共享的,但是线程死亡之后对堆的改变如果没有被其他使用,那改变的内存是可以被作为垃圾的;
此时主线程堆内存状态和子线程一样,是分配失败后进行过两次GC之后的状态;
子线程里放入大对象时,发现继续放入会造成内存泄漏,所以最后这个8mb对象就没有放入,但还是会给程序反馈报异常,主线程依然可以使用堆内存;
当一个线程抛出OOM异常后,它所占据的内存资源并不会马上全部被释放掉,而是失去引用,可以被垃圾回收,从而不会影响其他线程的运行。
垃圾回收器
串行
- 单线程(在垃圾回收发生时,其他线程都暂停,由一个线程来完成垃圾回收)
- 堆内存较小,适合个人电脑(CPU核数少的)
吞吐量优先(吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间))
- 多线程
- 堆内存较大,多核 cpu(服务器电脑)
- 让单位时间内,STW 的时间最短 0.2 0.2 = 0.4,垃圾回收时间占比最低,这样就称吞吐量高
响应时间优先
多线程
堆内存较大,多核 cpu
尽可能让单次 STW 的时间最短 0.1 0.1 0.1 0.1 0.1 = 0.5
虽然有多个线程,但假设只有一个CPU,工作的时候多个线程轮流去争抢单核CPU的时间片,其实这个效率还不如单线程,就好比有多个保洁工人,但扫帚只有一把,那他们打扫卫生必须轮流使用这把扫帚,那这个效率显然跟一个人打扫的效率是一样的;
吞吐量指的是正常业务吞吐量要大;响应时间指的正常业务每次被打扰时间要短;
一个着重于垃圾回收次数,一个着重于每次垃圾回收的速度。
串行
Serial:新生代,复制算法;SerialOld:老年代,标记+整理算法
新生代和老年代的垃圾回收器是分别运行的,比如新生代内存不足,它是使用Serial来完成垃圾回收,等老年代空间不足,Serial完成新生代的Minor GC,SerialOld完成老年代的Full GC

吞吐量优先
-XX:+UseParallelGC ~ -XX:+UseParallelOldGC这两个开关在jdk1.8默认是开启的
ParallelGC:新生代并行垃圾回收器,复制算法;ParallelOldGC:老年代并行垃圾回收器,标记+整理算法
这两个开关只有开启其中一个,另一个也会同时开启

多个用户线程到达安全点停下来后,这时垃圾回收器会开启多个线程来进行垃圾回收,垃圾回收线程的个数默认情况下跟CPU核数相关,只要CPU核数小于一个值时,那这个线程数就跟CPU核数是一样的了,如图中4核CPU,将来就会开启4个垃圾回收线程来完成回收的工作,回收结束后再恢复其他线程的一个运行;
4核CPU都去忙垃圾回收了,所以在垃圾回收的过程中,CPU的占用率一下子会飙到100%,如果下次又遇到垃圾回收时又会飙到100%,这是此垃圾回收器在垃圾回收发生时对CPU利用率的一个影响,多个CPU要发挥它们的性能,大家一起上,赶紧把垃圾回收的工作做完好恢复其他用户线程的运行完成后续工作;
ParallelGC的一个特点是可以根据一个目标设置ParallelGC的工作方式:
-XX:+UseAdaptiveSizePolicy(自适应大小调整策略)、-XX:GCTimeRatio=ratio(主要调整吞吐量)、-XX:MaxGCPauseMillis=ms(调整暂停时间);ParallelGC 比较智能,它可以根据你的一个设定目标(目标1与目标2)来尝试去调整堆的大小来达到你期望的那个目标。
响应时间优先
ConcMarkSweep:并发+标记+清除
Concurrent并发的含义是指垃圾回收器在工作的同时,其他用户线程也能同时运行,就是用户线程和垃圾回收线程是并发执行的,都要抢占CPU,而Parallel并行的含义是指是多个垃圾回收器它们并行运行,但在垃圾回收期间它不允许其他的用户线程继续运行(STW),这样的话,那CMSGC它在某些时刻能够起到一个并发的效果,也就是它在工作的同时用户线程也能工作,这样就进一步减少STW的时间,当然它垃圾回收的某几个阶段还是需要STW,但是在垃圾回收的其中一些阶段是不需要STW的,它可以跟用户线程并发执行,CMSGC是工作在老年代的垃圾回收器
并发失败由CMSGC并发垃圾回收器退化到SerialOld单线程垃圾回收器

首先多个CPU开始并行执行,当老年代发生内存不足,那么执行线程都达到了安全点暂停下来了,暂停下来以后CMSGC就开始工作了,执行一个初始标记的动作,在这个初始标记动作的时候仍然需要STW,即其他用户线程阻塞暂停起来,因为初始标记很快就完成了,不会把堆内存所有的对象都列一遍,只会标记一些根对象,暂停时间非常短,等这个初始标记完成以后,接下来用户线程就可以恢复运行了,与此同时垃圾回收线程还可以继续并发标记,把剩余的那些垃圾找出来,这个时候是跟用户线程并发执行的,不用STW,所以此时响应时间很短,几乎不影响用户线程的工作,等到并发标记完成后,还要做一步重新标记,这一步又要STW,这是因为并发标记的同时用户线程也在工作,用户线程工作的时候有可能产生一些新的对象,改变一些对象的引用,就可能对垃圾回收做一些干扰,所以等到并发标记结束以后还要做一遍重新标记的工作,等重新标记完了,用户线程又可以恢复运行,这时垃圾回收线程再做一次并发清理。CMSGC整个工作阶段只有在初始标记和重新标记会造成STW,其他阶段都是并发执行的,所以它的响应时间非常得短,是一个响应时间优先的老年代垃圾回收器。
初始标记的并发线程数受-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads两个参数影响,ParallelGCThreads指并行的垃圾回收线程数,一般跟你的CPU核数一样,图中n=4,但并发的线程数通过ConcGCThreads设置,一般这个参数建议设置为并行线程数的1/4,即4核CPU,那它的值就是1。
CMSGC对CPU的占用不如ParallelGC的占用高,如4核CPU它只占了1核作为垃圾回收,对CPU的占用并不高,但是用户线程也在运行时就满足4核CPU都能使用上,但是其中1核CPU被垃圾回收线程占用了,所以用户工作线程只能占用原来的3/4,所以对整个应用程序的吞吐量是有影响的…
CMSGC在工作的过程中执行并发清理时,由于其他的用户线程还可以继续运行,其他的用户线程在运行的同时可能又会产生一些新的垃圾,所以这个并发清理的同时不能把一些新的垃圾干掉,所以得等到下一次垃圾回收时再清理这些新产生的垃圾,这些新垃圾称之为浮动垃圾,这些浮动垃圾得等到下次做垃圾回收时才能清理掉,但是就带来一个问题,因为在垃圾回收的过程中可能产生新的垃圾,它就不能想其他的垃圾回收器那样等到整个堆内存不足了才做垃圾回收,那样的话这些新垃圾就没处放了,所以要预留一些空间来保留这些浮动垃圾,由参数-XX:CMSInitiatingOccupancyFraction=percent控制CMSGC垃圾回收的时机,其含义是执行CMSGC垃圾回收的内存占比。
在重新标记阶段有一个特殊的场景,有可能新生代的一些对象会引用老年代的对象,如果在这个时候重新标记就会扫描整个堆,通过新生代去引用扫描一遍老年代的对象,做可达性分析,这样的话对性能影响会有些大,新生代创建的对象个数比较多,其中很多本身是作为垃圾的,如果再从新生代来找老年代,就算是找到了将来新生代的垃圾也会被回收掉,相当于在回收前多做了一些无用的查找工作,可以用参数-XX:+CMSScavengeBeforeRemark来避免此现象发生。
CMSGC有一个特点是在内存碎片比较多的时候,因为它是一种标记+清除算法,可能产生比较多的内存碎片,这样的话就会造成将来分配对象时Minor GC不足,结果老年代由于碎片过多也不足,则会造成由于碎片过多而并发失败,CMSGC就不能正常工作了,这时这个垃圾回收器就会退化为SerialOld,做一次单线程串行的垃圾回收,做一些整理,等碎片减少了才能继续恢复工作,一旦发生这种并发失败问题,垃圾回收的时间就会一下子飚上来,这是CMSGC的一个最大问题,垃圾回收的时间会一下子变得很长,导致本来是一个响应时间优先的垃圾回收器,结果响应时间一下子变得很长,这样就给用户带来不好的体验。
G1(Garbage First/One)
G1是一款比较有年头的垃圾回收器,只是一直不太成熟,到最近其技术有一些突破才被广泛应用起来;
JDK9一个重要改进就是已经废弃了之前的CMSGC,由G1取代CMSGC成为JDK9默认的垃圾回收器

G1可以在用户线程工作的同时垃圾回收线程也能并发的执行,也可以设置一个参数-XX:MaxGCPauseMillis=time来设置暂停目标,默认200ms,是一种低延迟效果,当然也可以把目标设高些来提升吞吐量
1)G1 垃圾回收阶段
G1 垃圾回收的三个阶段是参考oracle工程师的一个说法

2)Young Collection
刚开始这些划分的区域都是白色的,表示空闲的区域



3)Young Collection + CM
初始标记:找到那些根对象并标记,在新生代GC发生STW时会对根对象做初始标记
并发标记:从根对象出发,顺着GC Root引用链找到其他的引用对象并标记

-XX:InitiatingHeapOccupancyPercent=percent设置为45%时,当老年代区域(O)占整个堆内存的45%时就开始并发标记
4)Mixed Collection
旧的老年代区域将无用对象采用复制算法复制到新的老年代区域,有大量对象由一个区复制到另一个区,时间较长,就达不到参数-XX:MaxGCPauseMillis=ms控制最大暂停时间的目标值了,为达到此目标,G1就会从这些老年代区域了挑出那些回收价值最高的区域,也就是挑出的区域如果回收了能够释放的空间比较多,那就只挑其中一部分区域来进行一个垃圾回收,这样的话复制的区域就少了,自然暂停时间的目标值就能达到了,需要垃圾回收的时间就变短了,当然,如果要复制的对象没那么多,且暂停时间的目标也能达到,就会把所有的老年代区域都进行一个复制工作,一方面是为了保留那些存活对象,另一方面是为了整理内存减少空间碎片,这是混合收集的工作方式,这就解释了问什么官方要叫Garbage First了,它是在混合收集阶段优先要回收那些垃圾最多的区域,主要的目的就是达到暂停时间短的目标
最终标记完成了,就会对存活的对象进行一个拷贝(对老年代来讲不是所有的老年代区域的都拷贝,是先回收那些回收价值最高、能够释放较多空间的区域,然后再进行剩余老年代区域的拷贝(红色区域))

5)Full GC
- SerialGC(串行)
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足发生的垃圾收集 - full gc
- ParallelGC(并行)
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足发生的垃圾收集 - full gc
- CMS(并发)
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足
- G1(并发)
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足

G1:并发垃圾收集阶段(回收速度比新产生的垃圾快),虽有暂停,重新标记和最后数据拷贝的过程还会有暂停,暂停时间相对较短,还称不上full gc。判断依据是观察GC日志,打印出Full GC字样才触发了Full GC。G1或CMS工作在并发收集阶段,回收速度高于垃圾产生速度,后台的回收日志里是不会有Full GC的字样的。
6)Young Collection 跨代引用
新生代垃圾回收的过程:首先找到根对象,根对象可达性分析再找到存活对象,存活对象进行复制操作至幸存区,这里就有一个问题就是要找这个新生代对象的根对象,通过根对象进行一个查找,那首先要找根对象,根对象有一部分来自老年代…

标记为脏卡后,做GC Root遍历时就不用去找整个老年代了,只需关注这些脏卡对象或区域

新生代垃圾收集时,跨代引用是利用卡表和Remember Set的技术加速新生代的垃圾回收
7)Remark


图中B首先为灰色,由于有强引用引用着它,所以最终会变为黑色而存活,当处理到C时,因为处于并发标记阶段,这就意味着同时有其他用户线程对对象的引用做修改,把B对C的引用给断了,那处理完B就处理C了,这时GC发现B与C之间已经没有联系了,所以处理到C时就会进行一个标记表明C将来是白色,等整个并发标记结束以后,C由于仍然是白色最终会被当做垃圾被回收掉,这是情况一;在C与B处理完以后,并发标记可能还没有结束,这时用户线程又改变了C的引用地址,把C对象当成A对象的属性作为一次赋值操作,C的引用又发生改变了,这个时候问题就来了,因为C之前已经处理过了,GC认为C已经是白色的了,那A又是黑色的,已经处理过了,以后不会处理它了,所以等到整个并发标记结束以后C就被遗漏了,GC仍然认为C是白色的,是垃圾,就把它给回收掉了,这样就不对了,因为这时有一个强引用引用着C,那再把它回收掉的话,这个伤害就大了,所以要对对象的引用做进一步的检查,即Remark重新标记阶段,就是为了防止这样的现象发生的。具体的做法是当对象的引用发生改变时,JVM就会给它加一个写屏障,写屏障就是只要你的对象引用发生改变,那这个写屏障的代码就会被执行。如刚才C的引用给A的其中一个属性,那么C的引用就发生了变化,这时写屏障的指令就会被执行,指令会把C加入到一个队列中,并且把C由白色变为灰色表示还没有处理完,等整个并发标记结束了,接下来进入重新标记阶段,会进行STW,让其他用户线程都暂停,这时重新标记的线程就会从这个队列中把队列中的对象一个个取出来再做一次检查,若发现是灰色的,那还要做进一步的判断处理,结果会发现A强引用着C,因此还应该把C变为黑色,这样C对象就不会被误当成垃圾被回收掉了。
Remark:采用一个pre-write barrier写屏障技术,在对象的引用被改变前把对象加入到一个队列satb_mark_queue里表示未被处理的,将来Remark阶段就可以配合这个队列来对这些对象做进一步的判断
8)JDK 8u20 字符串去重
- 优点:节省大量内存
- 缺点:略微多占用了 cpu 时间,新生代回收时间略微增加
- 在总的性能来看,此功能带来的收益是远远高于它所占用的CPU时间和新生代垃圾回收的时间的
-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[] - 注意,与
String.intern()不一样String.intern()关注的是字符串对象- 而字符串去重关注的是
char[] - 在 JVM 内部,使用了不同的字符串表
9)JDK 8u40 并发标记类卸载

10)JDK 8u60 回收巨型对象
G1希望巨型对象越早回收越好,最好是在新生代垃圾回收时就把它处理掉

11)JDK 9 并发标记起始时间的调整
JDK9对G1这个垃圾回收器有很多的功能增强,其中一项比较重要的是它并发标记时间的一个调整,G1也会面临一个Full GC的问题,如果垃圾回收的速度跟不上垃圾产生的速度,最终也会退化为Full GC的,之前说G1垃圾回收时Full GC是单线程其实不太正确,在现在G1版本中,即使是Full GC也早已变成了多线程,但Full GC它STW的时间肯定更长,所以还是要尽可能得避免Full GC的发生,因此可以提前让垃圾回收开始,让并发标记、混合收集提前开始,这样就能够减少Full GC发生的几率。
动态调整-XX:InitiatingHeapOccupancyPercent可以尽可能避免并发垃圾回收退化为Full GC

12)JDK 9 更高效的回收
- 250+增强
- 180+bug修复
- https://docs.oracle.com/en/java/javase/12/gctuning
垃圾回收调优
预备知识
掌握 GC 相关的 VM 参数,会基本的空间调整
掌握相关工具
明白一点:调优跟应用、环境有关,没有放之四海而皆准的法则
1)调优领域
- 内存
- 锁竞争
- cpu 占用
- io
2)确定目标
Hotspot:CMS、G1、ZGC、ParallelGC
ZGC:JDK12引入的一个处于体验阶段的垃圾回收器,其目标也是超低延迟

3)最快的 GC
答案是不发生 GC
执行一条没有限制大表SQL查询,它会把所有的数据都从数据库通过JDBC读取你的Java内存,这样的话,你的内存再大也架不住好多个这样的SQL语句同时执行,多个数据不断地多次把大量的数据加载到你的堆内存中,会导致你的GC频繁发生,甚至最后OutOfMemory也是有可能的,所有在SQL语句中加入limit n限制返回的记录总数,避免一些无用的数据都放在Java的内存里

4)新生代调优
只有排除了自己的代码问题以后才进行一个内存调优,内存调优这块建议先从新生代开始。当你new一个对象时,这个对象首先会在伊甸园中分配,分配速度是非常非常快的,即TLAB,全称Thread-Local Allocation Buffer,从名字可以猜到是每个线程局部的、私有的,Allocation Buffer含义是分配一个缓冲区;
在做对象的内存分配时也要做一个线程的并发安全保护(JVM来完成);
TLAB可以减少线程之间对内存分配时的一个并发冲突;
伊甸园中创建的对象效率还是非常高的;
新生代发生垃圾回收时,之前介绍的所有垃圾回收器它们采用的都是复制算法,复制算法的特点是把伊甸园、幸存区From中的幸存对象都复制到幸存区To,复制完以后伊甸园和幸存区From里面的内存都被释放出来了,因此这个死亡对象的回收代价为零;新生代中大部分的对象都是用过即死,当垃圾回收时,绝大部分的新生代对象都会被回收,只有少数对象存活下来,正因为新生代幸存对象很少又采用复制拷贝算法就导致Minor GC的时间非常的短,远远低于老年代发生内存不足触发Full GC的时间,这个时间是相差一到两个数量级,所以要做新生代的内存调优,做整个Java堆内存的调优一般都是从新生代开始,因为新生代优化的空间更大。

虽然新生代的内存调优最有效的方式是把新生代的内存加大,但是需要注意,新生代在调大的情况下它会存在一些问题。新生代堆内存设小了,新生代可用空间少了,创建新对象时一旦发现新生代的空间不足就会触发新生代的Minor GC,Minor GC一发生就会造成STW,短暂的暂停时间触发时机增多;新生代内存设置大了也不一定好,如果新生代的内存设得太大了,必然老年代的可用空间就相对变少了,那老年代的空间少了,将来新生代觉得空闲空间很多,新创建的对象都不会触发垃圾回收,但是老年代的空间紧张,再触发垃圾回收那可就是Full GC,那Full GC的暂停时间就比新生代的Minor GC暂停时间更长。
-Xmn
Sets the initial and maximum size (in bytes) of the heap for the young generation (nursery). GC is performed in this region more often than in other regions. If the size for the young generation is too small, then a lot of minor garbage collections are performed. If the size is too large, then only full garbage collections are performed, which can take a long time to complete. Oracle recommends that you keep the size for the young generation greater than 25% and less than 50% of the overall heap size.
设置新生代堆的初始和最大大小(字节)。GC 在该区域比在其他区域执行得更频繁。如果新生代的大小太小,则会执行大量的小规模垃圾回收。如果大小过大,则只完全垃圾回收,这可能需要很长时间才能完成。Oracle 建议将新生代的大小保持在整个堆大小的 25% 以上和 50% 以下。

真正实践的时候,新生代的内存跟吞吐量(单位时间能响应的请求数)的关系如上图所示,随着新生代的堆空间越来越大,吞吐量也越来越高,因为垃圾回收占用整个CPU计算时间的比例少了,吞吐量就高了,但是到了一定空间大小以后,吞吐量会有一个下降,这是因为虽然新生代空间大了,就意味着垃圾回收的时间较长,打个比喻,一栋楼房子大了,打扫的时间也会较长,所以这个曲线看出新生代空间也不是越大越好,但是需要在新生代的内存大小和吞吐量之间找到一个最优解
总的原则还是将新生代的空间调得尽可能大,因为还有一个因素是新生代的垃圾回收都是复制算法,复制算法也是分为标记+复制两个阶段,这两个阶段中复制花费的时间要多一些,因为复制要牵扯到对象占用的内存块移动,另外要更新其他引用对象的地址,这个速度相对更耗时一些…


一方面希望存活时间比较短的对象留在幸存区以便在下次垃圾回收时把它回收掉,另一方面又希望长时间存活的对象应该尽快晋升到老年代去,它留在幸存区里只能够耗费幸存区的内存,又因为新生代都是复制算法,会幸存区中对象下次存活了把它们都从From复制到To去,新生代复制算法主要的耗费时间是在对象的复制上,如果有大量长时间的存活对象不能及早的晋升,就相当于留在幸存区中被复制来复制去,这样对性能反而是一个负担,这时可以设置参数-XX:MaxTenuringThreshold=threshold来调整最大晋升阈值,把晋升阈值调得比较小,让那些长时间存活的对象能够尽快的晋升到老年代去;
每次把幸存区中不同年龄的对象所占用的空间打印出来可以更细致地去决定到底把这个最大晋升阈值调成多少比较合适,让那些长时间存活的对象能够尽早晋升

5)老年代调优
CMS垃圾回收的同时其他用户线程也在运行,就有可能产生新的垃圾,即浮动垃圾,浮动垃圾积累多了又导致内存不足就会造成CMS并发失败,那么CMS垃圾回收器就不能正常工作了,就会退化为SerialOld串行的老年代垃圾回收器,效率特别低,一下子就会STW,导致响应时间特别长,所以给老年代规划内存时可能需要给它规划得更大些,越大越好,这是为了预留更多的空间,避免浮动垃圾引起的并发失败;
如果程序运行一段时间以后,并没有发现Full GC,即不是由于老年代的空间不足引起的垃圾回收,说明老年代的空间很充裕,没有Full GC那系统工作已经是很ok的,所以先别尝试做老年代的调优,即使发生了Full GC,你也应该尝试先从新生代开始调优,去调整新生代的大小、幸存区大小,包括幸存区的那些晋升阈值、容量等。这些手段都用了还是经常发生Full GC再回过头来看看老年代的设置,一旦老年代发生Full GC,观察Full GC时老年代是由于超过了多大的内存导致了Full GC的发生,那可以在Full GC时原有的内存基础上给它调大到1/4~1/3,这样就相当于划分更合理的、更大的内存给老年代使用,减少老年代Full GC的产生

6)案例
- 案例1 Full GC 和 Minor GC频繁
程序运行期间,其GC特别频繁,尤其是Minor GC,甚至达到1分钟上百次,那GC特别频繁就说明空间紧张,如果是新生代空间紧张,当业务高峰期来了,大量的对象被创建,很快就把新生代的空间塞满了,塞满后就会带来一个后果,就是幸存区空间紧张了,它里面的对象晋升阈值就会降低,导致很多本来生命周期很短的对象也被晋升到老年代去,那情况就进一步恶化了,老年代就存了大量这种生命周期很短的对象,然后进一步触发老年代的Full GC的频繁发生。经过分析后通过监测工具观察这个堆空间大小,确实发现新生代内存设置得太小了,根据之前调优知道,内存优化应该先从新生代开始,所以解决方法是尝试增大新生代的内存,新生代内存增大以后内存充裕了,那新生代的垃圾回收就变得不那么频繁了,同时增大了幸存区的空间以及晋升的阈值,这样就可以让很多生命周期较短的对象尽可能地被留在新生代里而不要晋升到老年代去,进一步让老年代的Full GC也不容易出现或不那么频繁了
- 案例2 请求高峰期发生 Full GC,单次暂停时间特别长 (CMS)
此案例为业务需求要的是低延迟,所以在垃圾回收器选择上选择了CMSGC。单次暂停时间特别长就分析到底是哪一部分时间耗费的较长,因为已经确认了垃圾回收器是CMSGC,所以就去查看GC日志看看CMS哪个阶段耗费的时间较长。CMS分为初始阶段、并发标记阶段、重新标记阶段、并发清理阶段,其中初始标记和并发标记都是比较快的,比较慢的是这个重新标记,通过查看GC日志,它会把每个阶段耗费的时间在GC日志里找到,这时发现在重新标记阶段的耗时接近了1s多快到2s,所以问题就定位到重新标记阶段,在CMS做重新标记时会扫描整个堆内存,不光要扫描老年代的对象还要扫描新生代对象,如果这时业务处于高峰期,那新生代的对象个数比较多,那它扫描和标记的时间就会变得非常多,因为它要根据这个对象找到其引用,是一种遍历算法,耗时太多了,那能不能在重新标记之前把一些新生代的对象先做一次垃圾回收,减少新生代对象的数量,这样就可以减少在重新标记阶段所耗费的时间呢?可以通过设置参数-XX:+CMSScavengeBeforeRemark,即在重新标记发生之前先对新生代的对象做一次垃圾清理,清理之后存活对象就少了,那需要重新标记阶段需要查找和标记的对象也变得比以前要少得多,这样就可以解决问题了。通过这个参数发现,重新标记的时间从接近2s降低到了3ms左右了,就达到了响应时间的要求了,解决了单次暂停时间特别长的问题。
- 案例3 老年代充裕情况下,发生 Full GC (CMS jdk1.7)
CMS会可能由于空间不足导致并发失败,或空间碎片比较多触发Full GC,但经过排查在GC日志里都没有并发失败,包括碎片过多这样的错误提示,那说明老年代的空间是充裕的,不是由于老年代的空间不足产生的Full GC,后来就想到这个应用程序不符合JDK的版本,是1.7而不是1.8,1.8是有一个元空间作为方法区的实现,而1.7以前的JDK是采用的永久代作为方法区的实现,那1.7以前(0~1.6)的JDK版本里,永久代的空间不足也会导致Full GC垃圾回收的发生,到了1.8以后因为改成了元空间,它的垃圾回收就不是由Java来控制的了,所以元空间默认情况下其内存空间是使用的操作系统的内存空间的,所以它的空间容量一般是比较充裕的,不会发生这个元空间的空间不足问题,而1.7以前的永久代的空间如果设小了就会触发整个堆的一次Full GC,所以经过这样的定位就初步定位到是由于元空间的内存不足导致的Full GC,所以增大了元空间的初始值和最大值,保证了Full GC不会再发生。
类加载与字节码技术
- 类文件结构
- 字节码指令
- 编译期处理
- 类加载阶段
- 类加载器
- 运行期优化










To Be Continued.
