0
title: Java虚拟机
date: 2021-11-25 19:31:20
tags: Java虚拟机
categories: Java
cover: imgcat/jvm.png
0. JVM概述
JVM总览
定义
Java程序二进制字节码的运行环境
作用
装载Java程序的二进制字节码文件到其内部,将字节码文件解释编译为计算机可执行的机器执行令
特点
- 一次编译,到处运行
- 自动内存管理,具有垃圾回收功能
- 可对数组下标越界检查
- JVM运行在操作系统之上,与硬件没有直接交互
JVM,JRE,JDK的区别
1) JVM指的是运行Java字节码的虚拟机;
2) JRE指的是Java运行时环境,它包括JVM,Java基本类库等;
3) JDK指的是Java开发工具,包括JVM,Java类库和编译工具;
JVM的整体结构
JVM的生命周期
- 启动:Java虚拟机通过引导类加载器创建一个初始类来完成
- 执行:执行Java程序的过程,就是在执行Java虚拟机的过程
- 退出:1. 正常退出;2.程序遇到异常或错误异常终止JVM;3.操作系统出现错误;4.调 用Runtime类或System的exit方法可以终止Java虚拟机
1.内存结构
1.1 程序计数器
作用
用于保存Java虚拟机中下一条所要执行指令的地址
PC特点
- 线程私有
- 每个线程都有自己的程序计数器
- Java虚拟机可以执行多线程程序,CPU会为每个线程分配时间片;当当前线程的时间片使用完后就会去执行另一个线程的代码;
- 而程序计数器是每个线程私有的,也就是说当另一个线程的时间片用完,当前线程会抢到时间片后,会根据该线程程序计数器中的指令地址来找到对应的指令来执行当前的代码
- 不会存在内存溢出
- Java虚拟机规范中唯一不会出现OOM内存溢出的区域:Java虚拟机在设计的时候就将程序计数器设置为不会溢出
tips: CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片
1.2 虚拟机栈
1.2.1 定义
- 每个线程运行所需要的内存空间,称为虚拟机栈(Java Virtual Machine Stacks)
- 每个虚拟机栈由多个栈帧(Frames)组成;所谓栈帧就是线程中每次调用方法所占用的内存空间,用来存储局部变量,参数,返回值,返回地址
- 每个线程只能有一个活动栈帧,对应着正在执行的方法;活动栈帧就是虚拟机栈顶的栈帧
虚拟机栈的作用
- 负责Java程序的运行,保存方法的局部变量,方法内运算的结果,并负责方法的调用与返回
1.2.2 栈帧的内部结构
代码案例演示程序执行过程中栈帧内部的变化
public class _06LocalVirableTable{
public static void main(String[] args){
int i;
double j = 48;
String a = "hello";
i = 1;
System.out.println(a);
int k = method01(i);
}
public static int method01(int b){
int p = 17;
return b;
}
}
public class _06LocalVirableTable
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
//-----------------------------------常量池-----------------------------------------------
Constant pool:
#1 = Methodref #9.#28 // java/lang/Object."<init>":()V
#2 = Double 48.0d
#4 = String #29 // hello
#5 = Fieldref #30.#31 // java/lang/System.out:Ljava/io/PrintStream;
#6 = Methodref #32.#33 // java/io/PrintStream.println:(Ljava/lang/String;)V
#7 = Methodref #8.#34 // _06LocalVirableTable.method01:()V
#8 = Class #35 // _06LocalVirableTable
#9 = Class #36 // java/lang/Object
#10 = Utf8 <init>
#11 = Utf8 ()V
#12 = Utf8 Code
#13 = Utf8 LocalVariableTable
#14 = Utf8 this
#15 = Utf8 L_06LocalVirableTable;
#16 = Utf8 main
#17 = Utf8 ([Ljava/lang/String;)V
#18 = Utf8 args
#19 = Utf8 [Ljava/lang/String;
#20 = Utf8 i
#21 = Utf8 I
#22 = Utf8 j
#23 = Utf8 D
#24 = Utf8 a
#25 = Utf8 Ljava/lang/String;
#26 = Utf8 method01
#27 = Utf8 p
#28 = NameAndType #10:#11 // "<init>":()V
#29 = Utf8 hello
#30 = Class #37 // java/lang/System
#31 = NameAndType #38:#39 // out:Ljava/io/PrintStream;
#32 = Class #40 // java/io/PrintStream
#33 = NameAndType #41:#42 // println:(Ljava/lang/String;)V
#34 = NameAndType #26:#11 // method01:()V
#35 = Utf8 _06LocalVirableTable
#36 = Utf8 java/lang/Object
#37 = Utf8 java/lang/System
#38 = Utf8 out
#39 = Utf8 Ljava/io/PrintStream;
#40 = Utf8 java/io/PrintStream
#41 = Utf8 println
#42 = Utf8 (Ljava/lang/String;)V
//---------------------------------代码&函数-------------------------------------------
{
// 编译器初始化的构造函数
public _06LocalVirableTable();
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
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this L_06LocalVirableTable;
// 主函数(属于主函数栈帧)
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code: // 代码:JVM指令
stack=2, locals=5, args_size=1
0: ldc2_w #2 // double 48.0d
3: dstore_2
4: ldc #4 // String hello
6: astore 4
8: iconst_1
9: istore_1
10: getstatic #5
13: aload 4
15: invokevirtual #6
18: invokestatic #7 // Method method01:()V
21: return
LocalVariableTable: // 主函数局部变量表
Start Length Slot Name Signature
0 22 0 args [Ljava/lang/String;
10 12 1 i I
4 18 2 j D
8 14 4 a Ljava/lang/String;
// 函数1:属于函数1栈帧
public static void method01();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=0
0: bipush 17
2: istore_0
3: return
LocalVariableTable:
Start Length Slot Name Signature
3 1 0 p I
}
局部变量表
**局部变量表示一个数组,主要用来存储方法的参数和方法体内的局部变量值**(不是局部变量名!!!)数据类型包括:8种基本数据类型,对象引用(常量池存储的堆区地址),return
操作数栈
**操作数栈用来保存方法中计算过程的中间结果,同时作为计算过程中变量临时的存储空间**
动态链接
每一个栈帧内部都存在一个指向运行时常量池中该栈帧所属方法的引用
加入在一个方法中调用另外一个方法时,字节码里就是调用常量池中指向方法的符号引用的,动态链接就可以将这些符号引用转换为调用方法的真实引用
方法返回地址
方法返回地址记录的是调用该方法的pc寄存器的地址,也就是调用该方法的指令的下一条指令的地址。
1.2.2 问题辨析
- 垃圾回收是否涉及栈内存?
- 不需要! Java虚拟机是由一个个栈帧组成的,方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制收回内存。
- 栈内存分配越大越好吗(-Xss)
- 不是! 因为虚拟机栈所在的物理内存是一定的。栈内存越大,可以支持更多的递归调用,但是可以同时执行的线程会越少!
- 方法内的局部变量是否线程安全(局部变量线程私有)
- 如果方法内部的局部变量没有逃离方法的作用范围,则局部变量是线程安全的;
- 如果局部变量引用了对象并且逃离了方法的作用范围,则局部变量是线程不安全的;
- 如果传进来的参数是基本类型的话,也是线程安全的
ps: 线程安全:多线程中,多个线程对局部变量是否是共享的,若共享则线程不安全;如果变量对每个线程都是私有的,则是线程安全
关于StringBuilder是线程不安全和StringBuffer是线程安全这件事?
使用StringBuilder作为参数传入方法中,由于局部变量引用了对象,所以出了方法内部的变量会改变,所以是线程不安全的!但是不是说StringBuffer是线程安全的吗,把StringBuffer作为参数传入方法中,主线程的sb也改变了啊,这是为什么呢???
public static void main(String[] args) throws InterruptedException {
StringBuffer sb = new StringBuffer();
// StringBuilder sb = new StringBuilder();
sb.append(1).append(2).append(3);
// Thread.sleep(1000);
new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
method03(sb);
}, "线程1").start();
new Thread(null, ()->{
method03(sb);
}
, "线程2").start();
System.out.println(Thread.currentThread().getName() + ":" + sb);
}
// 不存在局部变量线程安全问题:
// 因为方法内部变量没有逃离方法方法的作用范围,所以是安全的
public static void method01(){
StringBuilder sb = new StringBuilder();
sb.append(4).append(5).append(6);
System.out.println(Thread.currentThread().getName() + ":" + sb);
}
// 存在局部变量线程安全问题:
// sb方法的参数,其他线程的sb对象可能被修改,多线程共享一个对象;改成StringBuffer是线程安全的
public static void method02(StringBuilder sb){
sb.append(4).append(5).append(6);
System.out.println(Thread.currentThread().getName() + ":" + sb);
}
// 这里我使用StringBuffer并没有体现出线程安全啊,主线程里的sb进入到线程1和线程2中依然被修改了
// 和StringBuilder有什么区别呢???
public static void method03(StringBuffer sb){
sb.append(4).append(5).append(6);
System.out.println(Thread.currentThread().getName() + ":" + sb);
}
1.2.3 内存溢出
Java.lang.stackOverflowError 栈内存溢出
栈内存溢出发生的原因:
- 虚拟机栈中的栈帧过多,导致溢出;(无限递归!)
- 每个栈帧所占的内存过大
使用第三方库有时会造成无限递归,进而栈溢出:
下面是使用Jackson工具包将Java对象转换为字符串的程序,由于打印的俱乐部对象中包含球员,球员对象中又包含俱乐部对象,进而形成无限递归即:
{name:"阿森纳"; 队员:"{name:"萨卡", 俱乐部:{name:"阿森纳"; 队员:"{name:"萨卡", 俱乐部:{......无限递归!!!}"}}"}
/**
* 使用Jackson将Java对象转换为json字符串
*/
public static void main(String[] args) throws JsonProcessingException {
Club ars = new Club();
ars.setName("阿森纳");
Player p1 = new Player();
p1.setName("萨卡");
p1.setClub(ars);
Player p2 = new Player();
p2.setName("史密斯 罗");
p2.setClub(ars);
ars.setPlayers(Arrays.asList(p1, p2));
ObjectMapper mapper = new ObjectMapper();
String s = mapper.writeValueAsString(ars);
System.out.println(s);
}
static class Player{
private String name;
@JsonIgnore
private Club club;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Club getClub() {
return club;
}
public void setClub(Club club) {
this.club = club;
}
}
static class Club{
private String name;
private List<Player> players;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<Player> getPlayers() {
return players;
}
public void setPlayers(List<Player> players) {
this.players = players;
}
}
Infinite recursion (StackOverflowError) (through reference chain: .Club["players"]->java.util.ArrayList[0]->.Player["club"]->.Club["players"]->java.util.ArrayList[0]
{"name":"阿森纳","players":[{"name":"萨卡"},{"name":"史密斯 罗"}]}
1.2.4 线程运行诊断
线程诊断案例一:CPU占用过多
/**cpu.java**/
public class cpu{
public static void main(String[] args){
while(true){
int i = 1;
}
}
}
诊断方法:
使用nohup java cpu & 在后台运行cpu.java这段代码
// nohup Command [ Arg … ] [ & ]
nohup java cpu &
使用top指令查询cpu运行状态
可以看到进程为147737的Java程序占用了99%的CPU运行时间的99.3%
使用ps指令进一步定位哪个线程对cpu占用过高
可以定位到是147737进程中的147738线程正在大量占用内存ps H -eo pid, tid, %cpu | grep 147737 (进程id)
使用jstack指令定位线程,查明原因
jstack 147738 (线程id)
线程诊断案例二:程序长时间运行没有结果(死锁)
class A{
int a;
}
class B{
int b;
}
public class DeadLock{
static A a = new A();
static B b = new B();
public static class MyThread1 extends Thread{
@Override
public void run(){
synchronized(a){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b){
System.out.println(Thread.currentThread().getName() + ": 获得锁a和锁b");
}
}
}
}
public static class MyThread2 extends Thread{
@Override
public void run(){
synchronized (b){
synchronized (a){
System.out.println(Thread.currentThread().getName() + ": 获得锁a和锁b");
}
}
}
}
public static void main(String[] args){
MyThread1 t1 = new MyThread1();
MyThread2 t2 = new MyThread2();
t1.start();
t2.start();
}
}
分析:线程1执行,将a对象所锁住;然后睡眠2秒,此时线程2抢到时间片,开始执行线程2;线程2立马将b对象锁住,然后想要锁住a对象,但是a对象已经被线程锁住,所以线程2需要等待线程1释放a对象;2s后线程1醒来,想要锁住b对象,但此时b对象已经被线程2锁住,所以线程1又要等待线程2释放b对象;进而造成了线程1等待线程2执行完毕,线程2又等待线程1执行完毕的尴尬局面😅。即造成了死锁。
诊断方法:
使用nohup java cpu & 在后台运行DeadLock .java这段代码
nohup java DeadLock &
# 可获取当前程序运行进程号:187389
使用jstack定位进程
1.3 本地方法栈
本地方法栈是为虚拟机执行本地方法所提供的内存空间
带有native关键字的方法就是本地方法。Java有时无法直接与操作系统进行交互,通过本地方法接口来调用c/c++编写的本地方法。
Object类中的本地方法:
1.4 堆
1.4.1 定义
Heap: JVM内存中最大的一块,用来存放new关键字创建的对象。
特点
- 线程共享,堆内存中的对象都需要考虑线程安全问题
- 具有垃圾回收机制,是垃圾回收管理的主要区域
- 几乎所有的对象实例以及数组都应当在运行时分配在堆上
- 方法结束后,堆中的对象不会被马上移除,仅仅在垃圾回收的时候才会被移除
存放的资源
- new出来的对象实例,基本数据类型的数组也是对象实例
- 串表(StringTable):存储String对象实例或者堆区中String对象的引用
- 静态变量:static修饰的变量
- 线程私有缓冲区(TLAB: Thread Local Allocation Buffer)
堆内存大小设置指令
-Xms Size : 设置堆区初始内存
-Xmx Size : 设置堆区最大内存
1.4.2 堆内存分区
堆内存细分
jdk 1.7之前,堆内存在逻辑上分为三部分:新生代+老年代+永久代
- 新生代(Young/New Generation): 包含伊甸园区(Eden)和幸存者区(Survivor)
- 老年代(Old/Tenure Generation)
- 永久代(Permanent Generation):逻辑上属于堆区,实际上在方法区(非堆)
jdk 1.8之后,堆在逻辑上分为三部分:新生代+老年代+元空间
就是将永久代替换成了元空间
各区默认配比:
- 老年代 :新生代 = 2 : 1
伊甸园区 : 幸存者区1区 :幸存者2区 = 8 : 1 : 1
通过虚拟机指令可以配置堆区的内存大小,以及各区占有的比例
1. 最小内存与最大内存设置:-Xms600m --Xmn600m
2. 新生代老年代比例:-XX:NewRatio=2
3. 伊甸园区幸存者区占比:-XX:SurvivorRatio=8
1.4.3 Java对象分配过程
- 对象创建好后,放入伊甸园区,如果伊甸园区放不下,会触发一次Young GC,清理伊甸园区和幸存者区中没有引用的对象。
- Young GC后,如果还放不下,即为巨型对象直接放入老年代里
- Young GC后,可以放下的话,将对象放在伊甸园区中
- 后面再次发生Young GC时,会将该对象一栋栋幸存者1区,如果此时幸存者0区中有对象存活也转移到幸存者1区,并且年龄计数器+1,当年龄技术器达到15后,就会将该对象转移至老年代
1.5 方法区
1. 方法区的结构
JDK 1.8 为什么要使用元空间代替永久代?
- 元空间使用的内存是计算机本地内存,其内存上限就是本地内存上限,而永久代分配在Java虚拟机内存中,其内存上限需要设置,但是其空间大小是很难确定的。比如在一些web工程中,又有功能较多,程序运行过程中动态加载了很多的类,容易产生永久代的OOM,使用本地内存就可以很好的避免这种内存溢出
- 永久代对象在full GC时进行垃圾回收,使用元空间………(这一点以后补充)
方法区主要存储什么内容?
方法区存储内容的演变
jdk 1.6将字符创常量池和静态变量都存放在永久代中,1.7之后就将他们转移至堆区了
类型信息
JVM在方法区中存储类的基本信息,包括类的全限定名,该类父类的全限定名,类的修饰符,类实现接口的列表
属性信息
JVM在方法区中保存类的属性信息和属性的声明顺序
方法信息
JVM在方法区中保存了方法的基本信息
运行常量池
- 常量池:字节码中存储的一张表,包含类执行所需要的类型、方法名、参数类型和字面量信息
- 运行时常量池:当字节码被加载值JVM内存中,它的常量池信息就会加载至运行时常量池中,并将里面的符号地址转换为真实地址
2. 字符串常量池
String的基本特性
- String内部再JDK8之前使用final char[] value 数组存储字符,在JDK9之后修改为final byte[] value byte型数据占一个字节,char型数据占两个字节,使用char[]数组更加节省空间
- String代表不可变的字符序列。简称:不可变性。因为byte[]数组使用final修饰,不可更改
- 通过字面量的方式定义的字符串直接存储在字符串常量池中
- 通过new关键字创建的字符串存储在堆中,同时在字符串常量池中创建一份
jdk 1.7中为什么要调整字符串常量池StringTable的位置?
jdk 1.7以前StringTable放在永久代的运行时常量池中,1.7将StringTable移动至堆区。因为永久代的垃圾回收效率很低,只有在Full GC是才会执行垃圾回收,这样就导致StringTable的回收效率不高,而字符串常量池中会存储大量的字符串,回收效率低会导致永久代内存溢出。放到堆区中,可以即时的回收内存!
字符串常量池的特征
- 常量池中的字符串只是符号,在用到时才会转换成真实的对象
- 使用字符串常量池可以避免创建重复的字符串对象
- 字符串常量的拼接原理是编译器优化
- 字符串变量的拼接原理是StringBuilder的拼接
- 使用intern方法,可以主动的将字符串常量池中没有的对象放入池中
public class StringTableTest {
public static void main(String[] args) {
String a = "a";
String b = "b";
String ab = "ab";
}
}
0 ldc #2 <a> // 将常量池中的"a"推到操作栈顶
2 astore_1 // 将栈顶数值"a"存入局部变量idx=1处,也就是 a = "a"
3 ldc #4 <b> // 将常量池中的"b"推到栈顶
5 astore_2 // 将栈顶数值"b"存入局部变量idx=2处,也就是 b = "b"
6 ldc #9 <ab> // 将常量池中的"ab"推到栈顶
8 astore_3 // 将栈顶数值"ab"存入局部变量idx=3处,也就是 ab = "ab"
9 return
当执行到 ldc #2 时,会把符号 a 变为 “a” 字符串对象,并放入串池中(hashtable结构 不可扩容)
当执行到 ldc #3 时,会把符号 b 变为 “b” 字符串对象,并放入串池中
当执行到 ldc #4 时,会把符号 ab 变为 “ab” 字符串对象,并放入串池中
最终StringTable [“a”, “b”, “ab”]
注意:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。
使用拼接字符串变量对象创建字符串的过程
public class StringTableTest {
public static void main(String[] args) {
String a = "a";
String b = "b";
String ab = "ab";
// 通过拼接字符串对象创建新的字符串
String ab2 = a + b;
// false ab2 = sb.append("a").append("b").toString(),在堆区中新建一个对象
System.out.println(ab == ab2);
}
}
0 ldc #2 <a>
2 astore_1
3 ldc #4 <b>
5 astore_2
6 ldc #9 <ab>
8 astore_3
9 new #10 <java/lang/StringBuilder> // 创建StringBuilder
12 dup
13 invokespecial #11 <java/lang/StringBuilder.<init> : ()V> // 初始化StringBuilder
16 aload_1 // 将局部变量表idx=1的值加载到栈顶
17 invokevirtual #12 <java/lang/StringBuilder.append : // 将栈顶的值添加至StringBuilder
20 aload_2 // 将局部变量表idx=2的值加载到栈顶
21 invokevirtual #12 <java/lang/StringBuilder.append : // 将栈顶的值添加至StringBuilder
24 invokevirtual #13 <java/lang/StringBuilder.toString : // 将StringBuilder转换为String
27 astore 4 // 将String存储到局部变量表idx=4中,即ab2 = "ab2"
29 return
- 使用拼接字符串常量的方法来创建新的字符串时,因为内容是常量,javac在编译期会进行优化,结果已在编译期确定为ab,而创建ab的时候已经在串池中放入了“ab”,所以ab3直接从串池中获取值,所以进行的操作和 ab = “ab” 一致。
- 使用拼接字符串变量的方法来创建新的字符串时,因为内容是变量,只能在运行期确定它的值,所以需要使用StringBuilder来创建
intern 1.8 方法
调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中
- 如果串池中没有该字符串对象,则放入成功
- 如果有该字符串对象,则放入失败
无论放入是否成功,都会返回串池中的字符串对象
注意:此时如果调用intern方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象
public class Main {
public static void main(String[] args) {
//"a" "b" 被放入串池中,str则存在于堆内存之中
String str = new String("a") + new String("b");
//调用str的intern方法,这时串池中没有"ab",则会将该字符串对象放入到串池中,此时堆内存与串池中的"ab"是同一个对象
String st2 = str.intern();
//给str3赋值,因为此时串池中已有"ab",则直接将串池中的内容返回
String str3 = "ab";
//因为堆内存与串池中的"ab"是同一个对象,所以以下两条语句打印的都为true
System.out.println(str == st2); // true
System.out.println(str == str3); // true
}
}
public class Main {
public static void main(String[] args) {
//此处创建字符串对象"ab",因为串池中还没有"ab",所以将其放入串池中
String str3 = "ab";
//"a" "b" 被放入串池中,str则存在于堆内存之中
String str = new String("a") + new String("b");
//此时因为在创建str3时,"ab"已存在与串池中,所以放入失败,但是会返回串池中的"ab"
String str2 = str.intern();
System.out.println(str == str2); //false
System.out.println(str == str3); //false
System.out.println(str2 == str3); //true
}
}
intern方法 1.6
调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中
- 如果串池中没有该字符串对象,会将该字符串对象复制一份,再放入到串池中
- 如果有该字符串对象,则放入失败
无论放入是否成功,都会返回串池中的字符串对象
注意:此时无论调用intern方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象
StringTable 垃圾回收
StringTable调优
- 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间-XX:StringTableSize=xxxx
- 考虑是否需要将字符串对象入池可以通过intern方法减少重复入池
1.6 直接内存
1.7 对象实例化过程
1.8 执行引擎
2. 垃圾回收
1. 垃圾回收概念
垃圾:在运行程序过程中没有任何指针指向的对象,这个对象就是要被回收的垃圾
为什么需要垃圾回收
- 如果不进行垃圾回收,内存迟早会消耗殆尽
- 垃圾回收还可以整理内存碎片,将分布零散的内存整理到内存的一端,进而划分出连续的空间,以便分给新的对象
JVM垃圾回收的作用域
垃圾回收主要作用于方法区和堆区,从频率上来讲:
- 频繁收集年轻代
- 较少收集老年代
- 基本不收集方法区
2. 垃圾回收算法
1.标记阶段
标记的目的:判断对象是否存活,判断死亡的对象,就会被垃圾回收器进行回收
判断对象存活的方法有两种:引用计数法和可达性分析算法
引用计数法
每个对象的对象头中保存一个计数器,有一个对象引用了它,就将它+1,引用失效时,就-1。只要引用计数器为0,就表明该对象已经没有被引用,可以进行垃圾回收
- 优点: 实现简单,判断效率高,回收没有延迟
- 缺点:计数器增加了内存消耗,循环引用会出现严重的问题
循环引用
A对象引用B,B对象引用A,它们有没有被其他对象引用,这就是循环引用
python中使用的引用计数法,python如何解决循环引用的呢?
- 手动解除
-
可达性分析算法
以根对象集合(GC Roots)为起点,按照从上到下的方式搜索被根对象集合所链接的目标对象是否可达
- 使用可达性分析算法后,内存中所有的存活对象都会被根对象集合直接或间接的连接着,搜索所走过的路径被称为引用链
- 在可达性分析算法中,如果没有任何引用链所引用,就意味着对象已经死亡,可以标记为垃圾对象
GC Roots
GC Roots可以是哪些元素
- 虚拟机栈的参数,局部变量引用的对象
- 方法区中静态属性引用的对象
- 本地方法栈中引用的对象
- 字符串常量池中引用的对象
- synchronized锁住的对象
- 一些常见的异常对象,类加载器对象等等
finalize
finalize()是对象被销毁前的回调函数,允许对象在被销毁前自定义处理逻辑,它允许在子类中被重写,常用于资源释放和清理的操作。
如果在finalize()方法中重新将要回收的对象建立了引用,对象可能会复活。但是这种垃圾回收对象的复活只会出现一次,以为一个对象的finalize()只能调用一次
2. 清除阶段
垃圾清除算法
常见的垃圾清除算法包含3类:
- 标记清除算法(Mark-Sweep)
- 复制算法(Copying)
-
标记清除算法
标记:遍历GC Roots所关联的对象,所有被引用的对象的对象头都标记为可达对象
- 清除:垃圾回收器对堆空间进行线性遍历,遍历到的对象如果对象头没有被标记为可达对象,就会被当做垃 圾回收
当堆中的内存空间被耗尽时,就会STW,即停止整个程序,调用垃圾回收线程,首先进行标记,然后清除
清除:这里的清除并不是真正的清除,而是将要清除对象的内存地址保存在空闲的地址列表中,下次有对象加载时,从该地址列表中判断是否有足够的空间防止对象,如果够,直接将原来的垃圾对象覆盖即可
标记-清除算法的缺点
- 标记-清除算法的效率一般,标记与清除都需要大量的运算
- GC时需要停止整个应用程序,用户体验较差
- 标记-清理算法会产生大量的垃圾碎片,清理出来的内存地址是不连续的,而且还需要额外维护一个空闲地址列表
标记整理算法
- 标记:和标记清除算法一样,遍历GC Roots所关联的对象,所有被引用的对象的对象头都标记为可达对象
- 整理:将所有存活对象(可达对象)都压缩至内存的一边,之后,清理外界的所有空间
标记整理算法相当于在标记清除的算法基础上添加了一个整理的操作
标记-清除算法与标记-整理算法的比较
- 相同点:都使用可达性分析算法,根据就GC Roots寻找可达对象,并标记
- 不同点:标记-清除算法会产生内存碎片,当有新对象产生时,需要根据空闲地址列表分配内存;标记-整理算法会将存活对象整理到连续的一段内存中,所以不会产生内存碎片,分配对象,只需修改分配起止点的标记指针即可
标记-整理算法的优缺点
优点:
- 消除了标记-清除算法中内存碎片的缺点,为新对象分配内存时,只需维护一个空闲内存的起始地址即可
- 消除了复制算法中,内存减半的高额代价
缺点:
- 由于多了一个整理内存的步骤,标记-整理算法的效率低于标记-清除算法的效率
- 移动对象时,如果对象被其他对象所引用,还需要调整对象的引用地址
- 垃圾回收过程中,主要暂停整个应用程序
复制算法
复制算法中将内存分为两个区域,From区和To区,其中To区为空,先将From区中的存活对象复制到To区中,再回收From区中的所有对象,然后交换From区和To区,这样可以解决内存碎片的问题,但是会使用双倍的内存空间
复制算法的优缺点
优点:
- 没有标记和清除阶段,实现简单,运行高效
- 复制过去,保证存活对象存储空间的连续性,不会出现内存碎片问题
缺点:
需要使用两倍的内存空间
复制算法的应用场景
复制算法适用于垃圾对象较多,这样存活对象移动到另一块空间的对象就少,效率就高,所以复制算法非常适合用于新生代
三种清除算法对比
标记清除 | 标记整理 | 复制 | |
---|---|---|---|
速率 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的2倍空间(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
3. 分代收集算法
为什么要使用分代收集算法?
新生代与老年代的对象生命周期是不一样的,新生代对象存活时间很短,存活数量较少;而老年代存活对象较多,且存在大对象,存活时间较长;所以应针对不同代采取不同的收集方式,以提高回收效率。
年轻代(Young Gen)
- 年轻代特点:内存区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
- 因为年轻代内存区域小, 即使使用复制算法, 浪费一般的内存不使用也还可以接受
- 这种情况使用复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
- 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
- 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
- 标记阶段的开销与存活对象的数量成正比。
- 清除阶段的开销与所管理区域的大小成正相关。
- 压缩阶段的开销与存活对象的数据成正比。
4. 五种引用
强软弱虚:强度逐级递减
强引用(Strong Reference):传统的引用定义,只要强引用关系还在,垃圾回收期就不会回收掉引用的对象
软引用(Soft Reference):垃圾回收时,内存不足时,会回收软引用所引用的对象
弱引用(Weak Reference):只要发生垃圾回收,就会回收软引用所引用的对象
虚引用(Phantom Reference):当一个对象设置虚引用,当它被垃圾回收时会受到一个系统通知,比如使用bytebuffer分配直接内存时,如果bytebuffer没有被强引用时将会被垃圾回收。但是bytebuffer申请的直接内存却不会被垃圾回收期所管理,所以将bytebuffer关联一个Cleaner虚引用对象,他会被放入一个虚引用队列中,然后调用Cleaner的clean方法来释放直接内存
终结器引用:用于实现finalize()方法,GC时,终结器引用入队,由Finalizer线程通过终结器引用找到被引用对象,然后调用他的finalize()方法,第二次GC时回收被引用的对象
3. 垃圾回收器
1. 垃圾回收器的分类
按线程数来分
- 串行垃圾回收器
同一时间段只允许有一个CPU用于垃圾回收操作,工作线程被暂停,直至垃圾回收结束
- 并行垃圾回收器
按工作模式来分
- 并发式垃圾回收器
垃圾回收线程可以和应用线程交替执行,可以减少STW,进而减少用户线程的暂停时间
- 独占式垃圾回收器
用户线程必须等到垃圾回收线程执行完毕后才能恢复工作
2. GC的评价指标
吞吐量
CPU运行用户代码的时间与CPU总消耗时间的比值
吞吐量优先的垃圾回收器,能够容忍较高的暂停时间,不考虑快速响应;
同时意味着单位时间内,STW的时间最短
暂停时间
垃圾回收线程执行垃圾回收的时候,用户线程等待垃圾收集线程收集完垃圾的时间
暂停时间优先的垃圾回收器,每次垃圾回收的时间很短,但是总的STW时间较吞吐量优先的垃圾回收器长。因此暂停时间优先的GC,延迟低,但是吞吐量也低
吞吐量和暂停时间是一对相互矛盾,互相竞争的目标
- 如果想提高吞吐量,就得减少GC的频次,导致单次GC的时间较长,延迟就较高
- 如果想要低延迟,就得减少单次GC的时间,这样就要频繁的执行GC,就会导致吞吐量的下降
现代垃圾回收器的标准
-
3. 常见的垃圾回收器
七种典型的垃圾回收器
Serial 回收器
Serial回收器(收集年轻代) : 该回收器使用的是 复制算法, 因为是串行回收器, 所以当垃圾回收的时候, 会产生STW。
- Serial Old回收器 (收集老年代) : 它使用的是 标记-压缩算法, 其他同上
ParNew 回收器
Serial回收器的多线程版本,多个垃圾回收线程执行垃圾回收操作,使用复制算法,存在STW
Par是Parallel的缩写,New:只能处理新生代
PS:
- 对于新生代,回收次数频繁,使用并行方式高效。
- 对于老年代,回收次数少,使用串行方式节省资源。(CPU并行需要切换线程,串行可以省去切换线程的资源)
Parallel 垃圾回收器
JDK 1.8中默认的垃圾回收器
Parallel Scavenge回收器:用于新生代的并行垃圾回收算法,吞吐量优先,使用复制算法
Parallel Old回收器:用于老年代的并行垃圾回收算法,吞吐量优先,使用标记-压缩算法
Parallel回收器与ParNew回收器的比较
- 两者都是并行回收器
- 和ParNew收集器不同,Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
- 自适应调节策略也是Parallel Scavenge与ParNew一个重要区别。
- 可以设置垃圾收集器最大停顿时间
- 可以自适应调整年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
CMS 垃圾回收器
CMS(Concurrent Mark Sweep): 低延迟的并发标记清除垃圾回收器
- CMS是第一款真正意义上的并发垃圾回收器,它实现了垃圾回收线程和用户线程同时工作
- CMS GC主要关注点是尽可能的缩短垃圾回收时用户线程的暂停时间,停顿时间越短,延迟越低,适用于与用户交互性强的程序,良好的响应速度可以提升用户体验
G1 垃圾回收器
G1(Garbage First): 区域化分代式并行垃圾回收器
G1的目标:在延迟可控的情况下,尽可能获取更高得到吞吐量
G1 GC使用的是标记压缩算法
4. CMS垃圾回收器
CMS工作原理 (必背重点)
CMS涉及4个主要阶段:初始标记阶段,并发标记阶段,重新标记阶段,并发清除阶段
- 初始标记阶段 (Initial Mark):STW,用户线程短暂的暂停,遍历堆对象,标记GC Roots直接关联的对象,标记完成立即恢复用户线程;由于只标记直接关联的对象,所以标记速度很快
- 并发标记阶段(Concurrent Mark):从GC Roots的直接关联值开始遍历整个对象图的过程,这个过程较长,垃圾回收线程与用户线程并发运行,无须STW
- 重新标记阶段(Remark):
并发标记过程中,可能会产生新的垃圾,重新标记是修改由于用户线程工作过程中导致原来的标记出现了改变,进而重新标记;但是并发标记时用户线程产生的新的浮动垃圾,重新标记阶段并不会被重新标记! 所以要再次STW,重新标记,这个标记过程较初始标记时间要长一些,但远比并发标记的时间短
这里很重要,之前的理解是错误的,见《深入理解Java虚拟机》3.4.6关于三色标记与增量更新的描述
- 并发清除阶段(Concurrent Mark):此阶段清除已经死亡的对象,释放内存空间,此阶段垃圾回收线程与用户线程并发执行
CMS特点
- CMS是一款并发的垃圾回收器,但是在初始标记和并发标记的过程中还是会出现STW,但时间都很短,CMS尽可能的降低延迟,确保用户的体验
- 由于在并发标记的过程中,用户线程仍在执行,还会产生新的垃圾,因此我们要确保程序具有足够的内存。内存不足时将会出现并发故障,虚拟机启动备用方案,使用串行的Serial Old收集器对老年代进行回收
- CMS收集器不能够像其他收集器等待老年代满了之后再垃圾回收,CMS需要在堆内存使用达到一个阈值后就进行垃圾回收,以确保在并发过程中,应用程序能够继续运行
- 由于CMS使用的标记-清除算法,所以会产生内存碎片,对象分配时需要维护一个空闲地址列表
CMS为什么不使用标记整理算法呢
因为并发清除过程中,用户线程仍在运行,使用整理算法的话就移动了对象的位置,这样用户线程中的引用就找不到了对象,所以对象的地址在用户线程运行的过程中不能改变
CMS的优点与缺点
- 优点:并发收集,延迟低
- 缺点:
- 最大化吞吐量:Parallel GC
- 最小化暂停时间: CMS GC
- 最小化并行开销:Serial GC
5. G1垃圾回收器
G1的分区 region
G1是一个并行回收器,它避免对整个堆空间进行垃圾回收,而是将堆空间划分为不同的分区(Region),不同的Region分表表示伊甸园区、幸存者区和老年区。G1每次回收不同的区域,并优先选择回收价值最大的区域,所以G1的名字叫做Garbage First,即垃圾优先!
- G1维护了一个优先列表,里面根据垃圾回收价值的大小存放回收区域的地址,所谓回收价值的就是回收所获得的空间大小与回收所需要时间的经验值
- G1是一款面向服务端的垃圾回收器,主要针多核CPU和大容量内存的机器,满足GC暂停时间的同时,还兼具较高的吞吐量。
G1垃圾回收阶段
在堆内存空间不足的情况下
- G1首先进行新生代伊甸园区的垃圾回收,年轻代的回收是独占式的并行收集,会产生STW,清理垃圾对象,将存活对象移动至幸存者区或者老年代
- 当堆内存使用达到45%时,开始并发标记老年代,标记老年代的可达对象
- 标记完成后,开始混合回收,将年轻代和老年代回收价值高的Region一起回收,G1将存活对象移动至空闲区间,这些空闲区间就成了老年代的一部分。
Remembered Set
- G1回收器分区的算法存在一个对象被不同区域引用的问题
- 一个Region不可能是孤立的,必定存在不同区域间的对象存在引用关系,那么进行可达性分析时,必须扫描整个Java堆才行吗?回收年轻代的时候也要同时扫描老年代吗?
- G1为了解决不同区域间对象引用的问题,给每一个分区设置了一个Remembered Set记忆集
- 如果往新生代中分配一个新对象,该对象的引用指向了老年代,就将老年代中对应卡表的地址记录在记忆集中,这样在并发标记的时候,只要根据记忆集遍历对应的卡表就行了,不需要遍历整个老年代
记忆集是一个Map, key是区域的起始地址,value是卡表元素的索引号
G1年轻代回收
年轻代回收过程
- 扫描GC Root寻找可达对象,扫描到的可达对象以及记忆集中的外区域对象作为可达对象
- 更新Remembered Set,当一个区域的对象引用另一个区域的对象时,会将该对象的卡表地址放入一个脏卡队列dirty card queue中,然后垃圾回收的时候,会将脏卡队列中的卡表更新到记忆集中
- 处理Remembered Set根据RSet找到老年代中的GC Root,然后标记指向伊甸园区的对象
- 复制对象,将伊甸园中的对象复制到幸存者区中
G1 并发标记
- Young GC会对老年代的GC Root进行初始标记
- 当老年代占用堆内存达到一定的阈值时,会进行并发标记
并发标记的过程与CMS并发标记类似
- 初始标记阶段:标记从GC Roots直接关联的对象,此阶段是STW,并会触发一次Young GC
- 并发标记阶段:对整个堆进行并发标记,垃圾线程与用户线程并发执行
- 重新标记阶段:修正并发标记阶段的标记结果,STW
- 并发清理阶段:识别并清理完全空闲的区域
G1 混合回收
当老年代的对象对内存的一定比例时,为了避免堆内存的耗尽,虚拟机会触发Mixed GC, Mixed GC会回收整个年轻代和部分老年代,此时的混合回收时并发回收的,因此还会产生新的垃圾
- 如果垃圾产生的速度大于垃圾回收的速度,这样会触发Full GC
- 如果垃圾产生的速度小于垃圾回收的速度,还是并发清理,不会触发Full GC
3. 类加载子系统
1. 类加载子系统
2. JVM的类加载过程
JVM加载类的过程主要有三个阶段加载、链接和初始化,其中链接阶段又包括验证、准备和解析三个阶段。
- 首先,在加载阶段,类加载子系统会从磁盘中以二进制字节流的方式读取字节码文件,然后将其转化为方法区中运行时的存储结构,进而生成代表此类的Class对象。
- 然后,在链接阶段,类加载子系统会先检查验证加载阶段生成的Class文件是否符合JVM规范,然后进入准备阶段为静态变量分配内存并初始化为0,接着再执行解析操作,就是把常量池中的符号引用转化为直接引用。
- 最后,进入类的初始化阶段,类的初始化就是执行类构造器clinit()的过程,Java虚拟机会保证类的构造方法的线程安全。
初始化阶段
- 初始化阶段就是执行类构造器方法clinit()的过程
- 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。也就是说,当我们代码中包含static变量的时候,就会有clinit方法
()方法中的指令按语句在源文件中出现的顺序执行 ()不同于类的构造器。(关联:构造器是虚拟机视角下的 ()) - 若该类具有父类,JVM会保证子类的
()执行前,父类的 ()已经执行完毕 - 虚拟机必须保证一个类的
()方法在多线程下被同步加锁
3. 类加载器的分类
类加载器用于实现类的加载动作,从Java虚拟机中的角度来看,只存在两种不同的类加载器:启动类加载器(Bootstrap ClassLoader)和自定义类记载器(User-Defined ClassLoader)。
从开发人员的角度来看,类加载器可以划分的更细致一些,包括启动类加载器、扩展类加载器和应用程序类加载器。当然用户也可以自定义类加载器。
- 启动类加载器存放于jre/lib目录,由底层的C++代码编写,Java程序无法直接引用。
- 扩展类加载器负责加载jre/lib/ext路径下的所有类库到内存中,开发者可以直接使用
- 用户程序类加载器负责加载用户类路径classpath上的指定类库,开发者可以直接这个类加载器。 | 名称 | 加载的类 | 说明 | | —- | —- | —- | | Bootstrap ClassLoader(启动类加载器) | JAVA_HOME/jre/lib | 无法直接访问 | | Extension ClassLoader(拓展类加载器) | JAVA_HOME/jre/lib/ext | 上级为Bootstrap,显示为null | | Application ClassLoader(应用程序类加载器) | classpath | 上级为Extension | | 自定义类加载器 | 自定义 | 上级为Application |
4. 双亲委派机制
所谓双亲委派机制,指的就是:当一个类加载器收到了类加载请求的时候,它不去直接加载指定的类,而是把请求委托给父加载器去加载。只有父加载器无法加载这个类时,才使用这个加载器来负责类的加载。
双亲委派加载过程
- 一个类加载器首先将类加载请求委托给父类加载器,只有父类加载器无法加载来,才尝试自己加载。
- 源码中ClassLoader类的loadClass()方法负责类的加载,首先会判断类有没有加载。如果没有被加载,再判断其是否存在父加载器。如果存在父加载器就调用父加载器的loadClass()方法,若不存在父加载器就直接调用启动类加载器来加载类。
- 启动类加载器加载失败,就使用当前类加载器来加载。如果所有的父类加载器都无法加载类,则有应用程序类在当前类路径下加载类。
双亲委派机制的好处
- 使用双亲委派机制可以避免类的重复加载,父加载器加载一个子类时,子加载器不会再次加载这个类
- 使用双薪委派可以保证类加载的安全性,避免Java的核心api被篡改!
ps1: JVM区分不同类的方式,不仅仅根据类名,相同的类被不同的类加载器加载产生的就是不同的类,如果不使用双亲委派机制的话,在当前路径下使用系统类,比如Object,findLoadedClass(Object)==null,就会显示没有被加载过,那么当前路径下的应用程序类加载器就会去系统目录下去加载Object,而JVM在启动的时候就已经加载过这些类了,所以就会出现类的重复加载!
ps2: 比如在定义一个java.lang.Integer类,加载Integer时是不会用应用程序类加载器去加载Integer类的,而是向上委托,使用启动类加载器加载jdk中的Integer类
父子加载器的关系是继承吗
不是!当前的ClassLoader中的parent属性来保存它的父加载器,所以父子加载器的关系是组合(Composition)
public abstract class ClassLoader {
// The parent class loader for delegation
private final ClassLoader parent;
}
怎么破坏双亲委派机制
破坏双亲委派机制,需要自己定义类加载器,并继承至ClassLoader,重写loadClass()与findClass()方法
- 如果没有重写loadClass()方法就默认走双亲委派模型。
- 重写findClass()方法为了满足在父类加载器不能满足类加载请求的情况下,使用该方法实现类的加载。
- defineClass()将字节码转化为Class对象
Tomcat为什么要破坏双亲委派机制,怎么破坏的
Tomcat是一个web容器,一个web容器中可能部署多个应用程序,而不同的应用程序可能会依赖不同版本的三方库,但是不同版本的类库某一个类的全路径名有可能是一样的,所以如果使用双薪委派模型的话就不能够在不同的应用中加载不同版本的类了,所以要打破双亲委派模型!
Tomcat的类加载机制:Tomcat为每一个web容器提供了一个单独的WebAppClassLoader加载器,为了实现容器的隔离性,Tomcat优先使用自己的类加载器WebAppClassLoader加载自身目录下的文件,如果加载不到再使用基础类加载器或启动类加载器来加载。