文章目录
- 简介
- 指令架构
- 寄存器指令架构
- 栈指令架构
- 生命周期
- 虚拟机启动
- 运行
- 退出
- 概览图
- 类加载
- 类加载子系统
- 加载
- 链接
- 验证
- 准备
- 解析
- 初始化
- 类加载器分类
- 引导类加载器
- 扩展类加载器
- 应用类加载器
- 加载路径
- 运行时数据区
- 概览
- 程序计数器
- 虚拟机栈
- 概述
- 栈帧
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
- 附加信息
- 本地方法栈
- 什么是本地方法
- 为什么要使用 Native Method
- 堆
- 概述
- 结构划分
- 堆内存设置
- 参数显示
- 堆内存大小设置
- 新生代老年代占比
- Eden 空间占比
- TLAB占比
- 新生代年龄
- 年轻的与老年代
- 对象分配
- MinorGC,MajorGC,FullGC
- MinorGC 触发机制
- MajorGC
- FullGC
- TLAB
- 逃逸分析
- 栈上分配
- 同步省略
- 分离对象,标量替换
- 方法区
- 方法区演进
- 方法区的内部结构
- 类型信息
- 域(Field)信息
- 方法信息
- 运行时常量池
- 方法区的垃圾回收
- 类的实例化
- 对象的创建步骤
- 对象的内存布局
- 执行引擎
- 解释器
- JIT(即时编译器)
- 为什么采用半编译半解释
- 解释器优势
- 编译器优势
- 何时JIT
- 执行引擎设置
- String
- String 的基本特性
- String 内存分配
- String的拼接
- String的intern()方法
- 垃圾回收
- 概述
- 垃圾标记算法
- 引用计数算法
- 可达性分析算法
- 对象Finalization机制
- 垃圾清除算法
- 标记清除
- 复制算法
- 标记压缩算法
- 分代收集
- 增量收集
- 分区收集
- Stop The World
- OOMAP
- 安全点安全区域
- 安全点
- 中断方式
- 安全区域
- 性能指标
- 垃圾回收器
- Serial 收集器
- ParNew 收集器
- Parallel 收集器
- CMS 收集器
- G1 收集器
- 优势
- Region
- Remembered Set
- 回收环节
简介
JVM 不仅仅可以用作运行Java,JVM自己定义了一套字节码规范,只要你的源代码可以编译成指定的字节码那么都可以被JVM运行。
JVM运行在操作系统之上,JVM负责
指令架构
指令集的架构一般分成两成,一种基于寄存器,一种基于栈。
寄存器指令架构
经典的寄存器指令架构x86,比如传统的PC,像是汇编。寄存器指令,高效,性能好,但移植性差。
mov ax,2
mov bx,3
add ax,bx
栈指令架构
java编译器输入的指令流基本上是一种基于栈的指令架构。相同的功能,基于栈的指令生成了更多代码。
int i = 2;
int j = 3;
int k = i + j;
---------------
0: iconst_2 //定义常量2
1: istore_1 //存入栈1位置
2: iconst_3 //定义常量3
3: istore_2 //存入栈2位置
4: iload_1 //加载1位置
5: iload_2 //加载2位置
6: iadd //相加
7: istore_3 //存入栈3位置
生命周期
虚拟机启动
java虚拟机的启动是通过引导类加载器(bootstrap loader)创建一个初始类(initial class)来完成,这个类由虚拟机的具体实现指定。加载所有运行程序的基础类的支持。
运行
执行所谓的java程序(我们写的),真真正正在执行的是一个虚拟机的进程,进程解析字节码执行。
退出
程序正常执行结束,遇到异常或错误,或由于操作系统错误引发退出。或调用Rumtime,System的 exit方法
概览图
类加载
类加载子系统
类加载子系统负责从文件系统或网络中加载Class文件,class文件在文件头有特殊标识。
ClassLoader只负责class文件的加载,至于它是否能够运行,则有ExecutionEngine决定。
加载的类信息被存放在方法区。除了类信息外,方法区中还会存放运行时常量池信息,还保存字符串字面量和数字常量。
加载
- 通过一个类的全限定名获取此类的二进制流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生产一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
链接
验证
- 确保Class文件的字节流中包含信息符合当前虚拟机的要求
- 文件格式验证,元数据验证,字节码验证,符号引用验证。
准备
- 为类变量分配内存并设置类变量的默认初始值,即零值。(真正的静态变量初始值在初始化阶段赋值)
- 这里不包含被final修饰的static,final在编译时就会被分配,准备阶段显示初始化。
public final static int i = 1; // final static在编译时已经分配,准备阶段显示赋值
public static int j = 2; // prepare 阶段 j 赋值为0
- 这个阶段不会为实例变量分配分配初始化,类变量会分配在方法区中,而实例变量和对象一起分配在堆中
解析
- 将常量池内的符号引用转换为直接引用地址。
- 事实上,解析操作往往会伴随这JVM在初始化完成之后执行
符号引用就是一组符号来描述引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的Class文件格式中。直接引用就是将目标的指针、相对偏移量或一个简介定位目标的句柄。
初始化
- 初始化阶段就是执行类(Class)构造器方法的过程;此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并来。clinit 方法按照指令在源文件中出现的顺序定义。静态变量的初始化,静态代码块都被合并到方法中。
- 若该类的有父类,JVM会保证子类的执行前,先执行父类的
public class ClassInit {
public static int i = 520;
public int j = 250;
public ClassInit() {
}
}
使用idea插件jclasslib查看ClassInit类的字节码。(字节码都是有规则的,jclasslib就当作字节码的格式化解释器)
其中init方法代表的类实例构造器。也可以看出init中的指令调用了父类构造器,初始化了 j 的值。
clinit方法代表这类的构造器,其中初始化类变量,将520赋值给了类变量 i 。
- 虚拟机必须保证一个类的 方法多线程下被同步加锁。 如例,线程加载DemoThread,执行clinit方法,clinit合并自静态变量赋值语句与静态代码块,静态代码块死循环无法结束,其他线程无法正常创建对象。
public class ClinitLock {
public static void main(String[] args) {
Runnable r = () -> {
System.out.println(Thread.currentThread().getName() + "开始");
DemoThread demoThread = new DemoThread();
System.out.println(Thread.currentThread().getName() + "结束");
};
Thread thread1 = new Thread(r,"线程1");
Thread thread2 = new Thread(r,"线程2");
thread1.start();
thread2.start();
}
}
class DemoThread {
static {
System.out.println(Thread.currentThread().getName() + "正在调用clinit方法");
if (true) {
while (true) {
}
}
}
}
类加载器分类
JVM支持两种类加载器引导类加载器、自定义类加载器,从概念上来说,自定义类加载指的是由开发人员自行定义的一类加载器,但java虚拟机规范却没有这么定义,而是将所用派生自抽象类ClassLoader的类加载器都定义为自定义类加载器
不论规范如何划分类加载器,程序开发中常见的就是三中:启动类加载器、扩展类加载器、引导类加载器。类加载器之间并不是继承,而是通过包含“父加载器”引用,保证逻辑结构
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
// 应用类加载器 sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println(contextClassLoader);
// 扩展类加载器 sun.misc.Launcher$ExtClassLoader@1b6d3586
System.out.println(contextClassLoader.getParent());
// 获取不到启动类加载器 null
System.out.println(contextClassLoader.getParent().getParent());
// 应用类加载器加载自定义类 sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println(ClassLoaderTest.class.getClassLoader());
// 扩展类加载器加载扩展类 sun.misc.Launcher$ExtClassLoader@1b6d3586
System.out.println(ZipFileStore.class.getClassLoader());
// 启动类加载器加载核心类库
System.out.println(String.class.getClassLoader());
}
}
引导类加载器
引导类加载器使用c/c++ 实现,嵌套在JVM内部
用来加载核心类库(JAVA_HOME/jre/lib/rt.jar,resource.jar或sun.boot.class.path路径下内容)用于提供JVM自身需要的类
并不继承自ClassLoader(用c写的)
加载扩展类和应用类加载器,并指定他们的父类加载器
出于安全考虑,bootStrapClassLoader只会加载包名为java、javax、sun等开头的类
扩展类加载器
java语言编写,由sun.misc.Launcher$ExtClassLoader实现
派生于ClassLoader
父类加载器为启动类加载器
从java.ext.dirs 系统属性指定的路径中加载类库,或从JDK安装目录的jre/lib/ext 目录下加载类库。如果用户自己的jar放在这个文件目录下也将有扩展类加载器加载
应用类加载器
java语言编写,由sun.misc.Launcher$AppClassLoader实现
派生于ClassLoader
父类加载器为启动类加载器
复制加载环境变量classpath或系统属性 java.class.path 指定路径下的类库
是默认类加载器,一般来说,java应用的类都是由他来完成加载
可通过ClassLoader.getSystemClassLoader()获取
加载路径
public class ClassLoaderTest {
public static void main(String[] args) {
// 引导类加载器目录
URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
Stream.of(urLs).forEach(System.out::println);
System.out.println("----------------------------------------");
// 扩展类加载器加载目录
String dirs = System.getProperty("java.ext.dirs");
Stream.of(dirs.split(";")).forEach(System.out::println);
System.out.println("----------------------------------------");
// 应用类加载器加载目录
String path = System.getProperty("java.class.path");
Stream.of(path.split(";")).forEach(System.out::println);
}
}
/**
file:/C:/Program%20Files/Java/jdk1.8.0_251/jre/lib/resources.jar
file:/C:/Program%20Files/Java/jdk1.8.0_251/jre/lib/rt.jar
file:/C:/Program%20Files/Java/jdk1.8.0_251/jre/lib/sunrsasign.jar
file:/C:/Program%20Files/Java/jdk1.8.0_251/jre/lib/jsse.jar
file:/C:/Program%20Files/Java/jdk1.8.0_251/jre/lib/jce.jar
file:/C:/Program%20Files/Java/jdk1.8.0_251/jre/lib/charsets.jar
file:/C:/Program%20Files/Java/jdk1.8.0_251/jre/lib/jfr.jar
file:/C:/Program%20Files/Java/jdk1.8.0_251/jre/classes
----------------------------------------
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext
C:\Windows\Sun\Java\lib\ext
----------------------------------------
C:\Program Files\Java\jdk1.8.0_251\jre\lib\charsets.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\deploy.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext\access-bridge-64.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext\cldrdata.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext\dnsns.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext\jaccess.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext\jfxrt.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext\localedata.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext\nashorn.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext\sunec.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext\sunjce_provider.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext\sunmscapi.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext\sunpkcs11.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\ext\zipfs.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\javaws.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\jce.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\jfr.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\jfxswt.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\jsse.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\management-agent.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\plugin.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\resources.jar
C:\Program Files\Java\jdk1.8.0_251\jre\lib\rt.jar
F:\javaSource\jvm-demo\target\classes
D:\IDEA\lib\idea_rt.jar
**/
运行时数据区
程序的运行(执行引擎的解析执行)依赖于运行时数据区,运行时数据区包括方法区、堆、栈、程序计数器、本地方法栈。方法区,堆是线程共享的。虚拟机栈,本地方法栈,程序计数器每个线程都有一份
概览
1.8之前使用方法区存储类元信息,常量池等信息,1.8之后使用元空间存储这些信息。(元空间使用堆外内存)
java.lang.Runtime 的实例代表着运行时数据区(每个Java应用都对应这一个单例的Runtime,Runtime可以通过Runtime.getRuntime()获取当前应用实例)
(图为java8)
程序计数器
CPU只有把程序状态到寄存器中才可以执行。JVM的程序计数器类似与对物理寄存器的抽象(地址寄存器),每个线程的程序计数器存储线程下一条指令的地址。执行引擎根据计数器的指示执行。
- 在JVM规范中,每个线程都有他自己的程序计数器,是线程私有的,计数器生命周期与线程生命周期一至
- 任何一个时间线程都只有一个方法在执行,也就是当前方法。程序计数器会存储当前线程正在执行的java方法的jvm执行地址。(如果执行的是native方法,则会指定为undefned)
- 就类似与cpu的地址寄存器,程序计数器是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础操作都需要依赖程序计数器。
- 字节码解释器工作时就是通过改变程序计数器的值来选取下一条需要执行的字节码指令
虚拟机栈
概述
由于跨平台的设计,java的指令使用栈结构来设计。不同于cpu架构采用寄存器指令架构。(使用寄存器指令架构更依赖于cpu)
- java虚拟机栈是什么?
java虚拟机栈(java virtual machine stack),每个线程创建时都会创建一个虚拟机栈,其内部保存一个个栈帧(stack frame),一个个栈帧对应这一次次方法调用。 - 主要作用
主管java程序运行,它保存方法的局部变量、部分结果、并参与方法的调用和返回。
栈帧
- 每个线程都有自己的栈,栈中的数据都是一**栈帧(stack frame)**的方式存储,线程正在执行的每个方法(层级调用)都对应各自的几个栈帧。栈帧是一个内存区块,是一个数据集,维系着方法执行过程中各种数据信息
- 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即当前正在执行的方法的栈帧(栈顶栈帧),当前栈帧对应的就是当前方法,定义当前方法的类也就是当前类
- 执行引擎运行的所有字节码指令只针对当前栈帧操作。
- 如果当前栈帧中调用了其他方法,那么新的栈帧将被创建放入栈顶,成为新的当前栈帧。
- java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另一种是抛出异常(未处理)。不管那种方式都会导致栈帧被弹出
局部变量表
局部变量表也被成为Local Variable,也被成为局部变量数组
- 定义为一个数字数组,主要用于存储方法参数和定义在方法中的局部变量,这些数据类型包括基本数据类型,对象引用(reference),以及returnAddress(返回值类型)
- 局部变量表是建立在线程上的,是线程私有的,所以不存在数据安全问题
- 局部变量表所需要的容量在编译期间已经确定,并保存在方法的Code属性的maximum local variables数据项中。方法在运行期间是不会改变局部变量表的大小的
- **局部变量表中的变量只在当前方法调用中生效。**在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量的传递。当方法调用结束后,随着方法栈帧销毁,局部变量表也将销毁
使用javap -v 查看类字节码,主要关注main方法编译后的字节码指令
7 public static void main(String[] args) {
8 int i = 10;
9 String str = "abc";
10 Object obj = new Object();
11 }
如下(//表示注释)
public static void main(java.lang.String[]);
// 参数与返回值类型,V表示void
descriptor: ([Ljava/lang/String;)V
// 访问修饰
flags: ACC_PUBLIC, ACC_STATIC
// code字节码指令
Code:
// locals4 就表示局部变量表长度,注意#2,#2,#1这都是常量池引用
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: ldc #2 // String abc
5: astore_2
6: new #3 // class java/lang/Object
9: dup
10: invokespecial #1 // Method java/lang/Object."<init>":()V
13: astore_3
14: return
// lineNumber表示程序行号,行号后面的数字表示,对应行的程序对应的字节码指令。如line 8:0 再看后面的 line 9:3可知 0到3行的字节码指令对应的是第8行的程序 int i = 10;
LineNumberTable:
line 8: 0
line 9: 3
line 10: 6
line 11: 14
LocalVariableTable:
// slot 变量槽
// signature中的L表示引用类型
// Name表示变量名
// Start与Length表示变量的作用域,Code字节码共15行
Start Length Slot Name Signature
0 15 0 args [Ljava/lang/String;
3 12 1 i I
6 9 2 str Ljava/lang/String;
14 1 3 obj Ljava/lang/Object;
变量槽也就局部变量表中占的位置(索引),32位类型占一个槽,64位类型long,double占两个槽,64位类型通过起始索引访问,如果方法是构造方法,或实例方法,对象引用this将会放在索引为0的槽中(jclasslib插件查看)
slot是可以重复利用的如果一个变量过了他的作用域那么之后声明的变量会占用他的位置,变量b过了自己的生命周期,下个变量占用他的位置
public void test2() {
int a = 10;
{
int b = 20;
b = b + a;
}
// 下面是访问不到b的
int c = a * 2;
}
注意看变量b的StartPc与Length描述的作用域防范是小于15的(15整个指令长度)
操作数栈
- 每个独立的栈帧中除了包含局部变量表外,还包含一个后进先出的操作数栈,也称为表达式栈 (使用数组结构)
- 操作数栈,在方法执行中,根据字节码指令,往栈中写入数据或提取数据,即入栈、出栈
- 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
- 操作数栈就是JVM执行引擎一个工作区(JVM基于栈指令架构),当一个方法刚开始执行的时候,一个新的栈帧随之创建,这时操作数栈是空的
- 每个操作数栈都会拥有一个明确的栈深用户存储,其所需的最大深度在编译期已经定义好了,保存在方法的Code属性中的max_Stack值中
- 栈中的可以存储任意的Java数据类型,32bit占一个栈深,64bit占两个
- 操作数栈只能通过 push,pop操作来完成一次数据访问
- 如果方法调用有返回值,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中的下一条需要执行的字节码指令
操作数栈实例
源码
public void test(){
byte a = 10;
int b = 20;
int c = a + b;
}
字节码
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
// 操作数栈2
stack=2, locals=4, args_size=1
// 入栈10
0: bipush 10
// 存入局部变量表 1 位置
2: istore_1
// 入栈20
3: bipush 20
// 存入局部变量表 2 位置
5: istore_2
// 入栈局部变量表 1 位置
6: iload_1
// 入栈局部变量表 2 位置
7: iload_2
// 栈顶两个位置相加
8: iadd
// 存入局部变量表 3 位置
9: istore_3
10: return
LineNumberTable:
line 6: 0
line 7: 3
line 8: 6
line 9: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/xxxx/OperandStackTest;
3 8 1 a B
6 5 2 b I
10 1 3 c I
执行流程图
字节码层面解释i++与++i的区别
5 public void test() {
6 int i = 10;
7 int k;
8 k = i++;
9 k = ++i;
10 }
前++的本质就是执行iload操作是在 iinc 之前还是在之后
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=3, args_size=1
0: bipush 10
2: istore_1
3: iload_1 // 加载局部变量表 1 号位置入栈
4: iinc 1, 1 // 局部变量表 1 号位置 自增1
7: istore_2 // 操作数栈顶存入局部变量表 2 号位置 (栈顶还是之前i的值,但此时变量表中i已经自增了)
8: iinc 1, 1 // 局部变量表 1 号位置 自增1
11: iload_1 // 加载局部变量表 1 号位置入栈(注意这时i已经自增完成)
12: istore_2 // 操作数栈顶存入局部变量表 2 号位置
13: return
LineNumberTable:
line 6: 0
line 8: 3 // 第8行的代码是从3行字节码开始的(k=i++)
line 9: 8 // 第9行的代码是从8行字节码开始的 (k=++i)
line 10: 13
LocalVariableTable:
Start Length Slot Name Signature
0 14 0 this Lcom/tttiger/OperandStackTest;
3 11 1 i I
8 6 2 k I
再来看一个有趣的例子,j的最终 结果是8
public void test() {
int i = 3;
int j = i++ + ++i;
}
Code:
stack=2, locals=3, args_size=1
0: iconst_3
1: istore_1
2: iload_1 // 先iload 后 iinc 后++完成
3: iinc 1, 1
6: iinc 1, 1 // 先iinc后 iload 后++
9: iload_1 // 此时 iload 局部变量表中的i已经变成5
10: iadd // 操作数栈的 3+5
11: istore_2 // 存储最终相加结果到局部变量表2位置
12: return
动态链接
指向运行时常量池的方法引用
- 每一个栈帧内部都包含一个指向运行时常量池该栈帧所属方法的引用。包含这引用的目的就是为了支持当前方法代码能够实现动态链接。比如 invokedynamic指令
- 在java源码被编译为字节码时,所有的变量和方法引用都作为符号引用Symbolic Reference保存在class文件的常量池中(注意区分class中的常量池与运行时常量池)。比如:描述一个方法调用另外其他方法时,就是通过常量池中指向方法的符号引用表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
方法返回地址
- 存放调用该方法的pc寄存器(程序计数器)的值
- 一个方法的结束有两种方式:1. 正常执行完成。2. 出现未处理的异常
- 无论通过那种方式退出,在方法退出后都返回该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的下一条指令地址,而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。本质上方法的退出就是当前栈帧出栈,出栈是需要恢复上层调用方法的局部变量表,操作数栈,将返回值压入调用着栈帧的操作数栈,设置pc寄存器(程序计数器)的值,让方法继续执行下去。
- 正常完成与异常完成的区别在于,异常完成退出不会给上层调用者返回任何值。
- 执行引擎遇到任意一个方法返回指令(return),会有返回值传递给上层的方法调用者,正常完成出口。(一个方法在正常的调用完成之后具体需要用那种返回指令还需要根据方法返回值来确定,ireturn,lreturn,freturn,dreturn,areturn,另外return表示返回void)
public void testA(){
try{
testB();
}catch (Exception e){
e.printStackTrace();
}
}
如果处理了异常,回去异常表中找到匹配的异常进行处理
public void testA();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=1
0: aload_0
1: invokevirtual #2 // Method testB:()V
4: goto 12
7: astore_1
8: aload_1
9: invokevirtual #4 // Method java/lang/Exception.printStackTrace:()V
12: return
Exception table:
from to target type
0 4 7 Class java/lang/Exception // 0到4行发生异常,跳转到7行处理
LineNumberTable:
line 7: 0
line 10: 4
line 8: 7
line 9: 8
line 11: 12
LocalVariableTable:
Start Length Slot Name Signature
8 4 1 e Ljava/lang/Exception;
0 13 0 this Lcom/tttiger/DynamicLinkTest;
附加信息
栈帧中还允许携带与java虚拟机相关的一些附加信息。例如对程序调试提供支持的信息。
本地方法栈
什么是本地方法
一个Native Method就是java调用非java代码的接口,一个native method的实现并非由java实现,例如可能是c实现,在定义个native method 并不需要提供实现体,因为其实现体是由非java程序实现的。本地接口的作用是融合不同的编程语言为java所用,他的初衷是融合c/c++程序。
为什么要使用 Native Method
java对底层的支持很有局限性,而c/c++可以很好的解决这个问题。
- 有时java应用需要与java外面的环境交互,这是本地方法存在的主要原因。如获取操作系统或某些硬件交换信息时的情况。本地方法真是这样一种交流机制:它为我们提供了一个非常简洁的接口,而我们无需去了解java应用之外的细节。
- JVM支持着java语言本身和运行时库,它是java程序赖以生存的平台,它由一个解释器(解释字节码)和一些链接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的操作系统。通过使用本地方法,我们得以用java实现了jre的与底层系统交互,甚至JVM的一些部分都是用c写的。还有,如果我们要使用到一些java语言本身没有提供封装的操作系统特性时,也需要使用到本地方法。
- Sun的解释器是用c实现的,这使得它能像一些普通的c一样与外部交互。jre大部分是用java实现的,它也通过一些本地方法与外界交互。例如:java.lang.Thread的setPriority()方法实现,它最终调用本地方法调用win32的SetPriority()来实现。
堆
概述
- 一个JVM实例只存在一个堆内存,堆也是java内存管理的核心区域
- java堆区在JVM启动时即被创建,其空间大小也就确定了。是JVM管理的最大的一块内存区域。
- 堆可以处在物理上不连续的内存中,但在逻辑上他被视为是连续的。
- 所有线程共享java堆,在这里还可以划分线程私有缓冲区(thread local allocation buffer,TLAB)
- **“几乎”**所有对象都被分配在堆中(逃逸分析,标量替换)
- 数组和对象可能永远不会存储在栈上,栈帧中保存引用,这个引用指向对象或数组在堆中的位置
- 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
- 堆是GC执行垃圾回收的重点区域
结构划分
现在的垃圾收集器大部分基于分代收集理论设计。
- java7及之前堆内存在逻辑上分为三部分:新生区(新生代)+老年区(老年代)+永久区(永久代)。(新生区有被划分为Eden区和Survivor区(Survivor区又分为s0和s1))
- java8以及之后内存逻辑上分为三部分:新生区(新生代)+老年区(老年代)+元空间。(新生区有被划分为Eden区和Survivor区(Survivor区又分为s0和s1))
堆内存设置
参数显示
-XX:+PrintFlagsInital 查看所有参数设置(默认值)
-XX:+PrintFlagsFinal 查看所有参数的最终值(可能会存在修改,不再是初始值)
堆内存大小设置
堆内存的大小在启动时已经设定好了,可以同过 -Xms 和 -Xmx 设置
-Xms 堆内存起始大小(-Xms1024m)
-Xmx 堆内存最大内存
通常将 -Xms 与 -Xmx 设置为相同的值,其目的是为了能够在垃圾回收清理完堆后不需要重新分割计算堆区的大小
默认情况下,起始内存大小为: 物理内存/64,最大内存为:物理内存/4
新生代老年代占比
默认 -XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
若修改为 -XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
(还可以使用 -Xmn100m 直接指定新生代的大小,直接指定后比例就不起作用了,平常不用)
Eden 空间占比
Eden与另外两个Survivor空间的占比默认是8:1:1(默认情况下其实不是,因为还有一个参数-XX:+UseAdaptiveSizePolicy,开启了自动分配,可以-XX:-UseAdaptiveSizePolicy 关闭) 需要指定 -XX:SurvisorRatio=8 显示指定Eden区占比为8
TLAB占比
-XX:UseTLAB 设置是否开启TLAB
-XX:TLABWasteTargetPercent 设置TLAB占比
新生代年龄
-XX:MaxTenuringThreshold 对象年龄超过指定值放入老年代(默认15)
年轻的与老年代
- 存储在JVM中的java对象可以划分为两类:1. 生命周期较短的瞬时对象,这类对象创建和消亡都非常迅速。 2. 生命周期非常长,在某些极端情况下可能与JVM的生命周期相同
- java堆进一步划分,可分为年轻代(youngGen)和老年代(oldGen)
- 年轻代又可以划分为Eden空间,Survivor0区,Survivor1区(可叫做From区,To区,From,To是不停交换的,复制算法回收)
对象分配
为对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配,分配在哪里,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
- new的对象先放在Eden区域,此区域又大小限制
- 当Eden区域满时,程序又需要创建对象,JVM的垃圾回收器将堆Eden区进行垃圾回收(Minor GC只再Eden区满时触发),将Eden区中不再被应引用的对象进行销毁。
- 将Eden区中幸存的对象移动到Survior区
- 如果再次触发GC(Eden Survivor都会扫描,Eden满后连带着扫描),此次幸存下来的对象放到Survivor1区
- 如果再次触发GC(Eden Survivor都会扫描),幸存下来的对象放到Survivor0区(from to 是动态变化的)
- 如果经历了15次(年龄计数器)GC都没有被回收掉对象将被放到Old区域(次数的可设置的)
- Old区内存不足时,再次触发GC(Major GC),对Old区进行内存清理
- 若Old区进行内存清理后依然无法保存对象,就会产生OOM
对象分配的特殊情况
MinorGC,MajorGC,FullGC
JVM再进行GC时,并非每次都对三个内存(新生代,老年代,方法区)区域一起回收。
针对HotSpot VM的实现,它里面的GC按照回收区域可分为两大类:1. 部分收集 2. 整堆收集
- 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为
- 新生代收集(MinorGC/YoungGC):只是新生代(Eden/S0/S1)的垃圾收集
- 老年代收集(MajorGC/OldGC):只是老年代的垃圾收集(目前只有CMS GC会有单独收集老年代的行为。很多时候Major GC会和Full GC混淆使用,具体要分表是老年代回收还是整堆回收)
- 混合收集(MixedGC):收集整个新生代以及部分老年代的垃圾收集。(目前,只有G1 收集器会有这种行为)
- 整堆收集:FullGC 收集整个java堆和方法区
MinorGC 触发机制
- 当年轻代空间不足时,会触发MinorGC,这里的年轻代是只Eden区域,Survivor满不会引发GC。(每次YoungGC会清理年轻代的区域包括Survivor)
- MinorGC会触发STW(stop the world),暂停其他用户的线程,等待垃圾回收结束,用户线程才恢复运行
MajorGC
- 指老年代空间不足时发生在老年代的GC,对象从老年代消失时,会说Major GC 或 Full GC发生了
- 出现MajorGC,经常会伴随至少一次MinorGC(但非绝对的,在Parallel Scavenge收集器的策略里就有直接进行Major的策略选择过程,也就是在老年代空间不足时,指定先触发minorGC,如果之后空间还不足触发Major GC)
- MajorGC的速度一般会比MinorGC满10倍以上,STW的时间更长
FullGC
- 调用System.gc() 时,系统建议执行FullGC,但不保证执行
- 老年代空间不足
- 方法去空间不足
- 通过MinorGC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区,Survivor 0 (From Space) 向 Survivor1 (To Space)区复制时,对象大小小于To Space可用内存,则把对象放入老年代,且老年代空间由放不下
TLAB
TLAB(Thread Local Allocation Buffer)
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在JVM中非常频繁,因此在并发情况下从堆区划分内存空间是线程不安全
- 为避免多个线程操作同一地址,需要使用加锁等机制,影响内存分配速度
- 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有的缓存区域(只有在分配的时候是私有的,加快分配速度),它包含在Eden中
- 多线程同时分配时,使用TLAB可以避免一系列的线程安全问题,同时还能够提升内存分配的吞吐量,因此可称之为快速分配策略
- 尽管不是所有对象实例都能够成功的在TLAB中成功分配,但在TLAB中分配是首选的(TLAB空间有限)
- 默认情况下,TLAB空间的内存非常小,仅占整个Eden空间的1%,当然可以通过“-XX:TLABWasteTargetPercent” 设置TLAB占用Eden的比值
- 一旦对象在TLAB空间分配内存失败,JVM会尝试通过加锁(CAS)直接在Eden中配分
逃逸分析
堆是对象分配的唯一选择么?随着JIT编译器的发展与逃逸分析技术的成熟,栈上分配,标量替换优化技术使得对象在堆中分配变得不是那么绝对了
- 一个对象实例的分配如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就有可能被优化为栈上分配。这样就无需再堆中分配,也无需进行垃圾回收。
- 逃逸分析的基本行为就是分析对象的动态作用域
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如(赋值给成员变量,静态变量,方法返回,引用方法传递)
栈上分配
栈上分配测试
// JVM 参数 -Xms1G -Xmx1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
public class EscapeTest {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("用时:"+(end-start));
}
public static void alloc(){
// 没有发生逃逸
EscapeTest test = new EscapeTest();
}
}
// JVM 参数 -Xms1G -Xmx1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
用时:78
Heap
PSYoungGen total 305664K, used 173015K [0x00000000eab00000, 0x0000000100000000, 0x0000000100000000)
eden space 262144K, 66% used [0x00000000eab00000,0x00000000f53f5f00,0x00000000fab00000)
from space 43520K, 0% used [0x00000000fd580000,0x00000000fd580000,0x0000000100000000)
to space 43520K, 0% used [0x00000000fab00000,0x00000000fab00000,0x00000000fd580000)
ParOldGen total 699392K, used 0K [0x00000000c0000000, 0x00000000eab00000, 0x00000000eab00000)
object space 699392K, 0% used [0x00000000c0000000,0x00000000c0000000,0x00000000eab00000)
Metaspace used 3236K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 350K, capacity 388K, committed 512K, reserved 1048576K
同样的代码开始栈上分配后
// JVM 参数 -Xms1G -Xmx1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
用时:4
Heap
PSYoungGen total 305664K, used 15729K [0x00000000eab00000, 0x0000000100000000, 0x0000000100000000)
eden space 262144K, 6% used [0x00000000eab00000,0x00000000eba5c420,0x00000000fab00000)
from space 43520K, 0% used [0x00000000fd580000,0x00000000fd580000,0x0000000100000000)
to space 43520K, 0% used [0x00000000fab00000,0x00000000fab00000,0x00000000fd580000)
ParOldGen total 699392K, used 0K [0x00000000c0000000, 0x00000000eab00000, 0x00000000eab00000)
object space 699392K, 0% used [0x00000000c0000000,0x00000000c0000000,0x00000000eab00000)
Metaspace used 3236K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 350K, capacity 388K, committed 512K, reserved 1048576K
// 内存较小时,关闭栈上分配,进行了垃圾回收
// JVM 参数 -Xms256m -Xmx256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
[GC (Allocation Failure) [PSYoungGen: 65536K->776K(76288K)] 65536K->784K(251392K), 0.0015262 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 66312K->776K(76288K)] 66320K->792K(251392K), 0.0007224 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
用时:51
Heap
PSYoungGen total 76288K, used 32046K [0x00000000fab00000, 0x0000000100000000, 0x0000000100000000)
eden space 65536K, 47% used [0x00000000fab00000,0x00000000fc989890,0x00000000feb00000)
from space 10752K, 7% used [0x00000000ff580000,0x00000000ff642020,0x0000000100000000)
to space 10752K, 0% used [0x00000000feb00000,0x00000000feb00000,0x00000000ff580000)
ParOldGen total 175104K, used 16K [0x00000000f0000000, 0x00000000fab00000, 0x00000000fab00000)
object space 175104K, 0% used [0x00000000f0000000,0x00000000f0004000,0x00000000fab00000)
Metaspace used 3236K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 350K, capacity 388K, committed 512K, reserved 1048576K
// 开启栈上分配后没有进行垃圾回收
// JVM 参数 -Xms256m -Xmx256m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
用时:4
Heap
PSYoungGen total 76288K, used 6554K [0x00000000fab00000, 0x0000000100000000, 0x0000000100000000)
eden space 65536K, 10% used [0x00000000fab00000,0x00000000fb166858,0x00000000feb00000)
from space 10752K, 0% used [0x00000000ff580000,0x00000000ff580000,0x0000000100000000)
to space 10752K, 0% used [0x00000000feb00000,0x00000000feb00000,0x00000000ff580000)
ParOldGen total 175104K, used 0K [0x00000000f0000000, 0x00000000fab00000, 0x00000000fab00000)
object space 175104K, 0% used [0x00000000f0000000,0x00000000f0000000,0x00000000fab00000)
Metaspace used 3231K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 349K, capacity 388K, committed 512K, reserved 1048576K
同步省略
在动态编译同步代码块的时候,JIT编译器可以借助逃逸分析来判断同步代码块所使用的锁对象是否只能被一个线程访问而没有被发布到其他线程。如果没有JIT编译器在编译这个同步代码块的时候就会取消这部分代码的同步。这样大大提高并发性和性能。这个取消同步的过程叫同步省略。这个取消同步的过程也叫锁消除
// 使用的锁对象只能被一个线程访问
public void test(){
// 每个线程调用都是一个新的实例
Object obj = new Object();
synchronized (obj){
System.out.println(obj);
}
}
// 在运行期间将会编译优化为(在字节码层面加锁还是存在的)
public void test() {
Object obj = new Object();
System.out.println(obj);
}
分离对象,标量替换
有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存在cpu寄存器中
标量 Scalar是只一个无法在分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为它可以分解为其他聚合量和标量
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过编译器优化,就会把这个对象替换成其中包含的若干个成员变量来替代。这个过程就是标量替换
public static void main(String[] args){
alloc();
}
private static void alloc(){
Point point = new Point(1,2);
System.out.print(point.x+"-"+point.y);
}
class Point{
private int x;
private int y;
}
经过标量替换后,就会变成
private static void alloc(){
int x = 1;
int y = 2;
System.out.print(x+"-"+y);
}
Point 这个聚合变量经过逃逸分析后,发现它并没有逃逸,就被替换成两个标量了。一旦替换为标量就不需要创建对象,那么就不需要分配堆内存了。标量替换为栈上分配提供了很好的基础
方法区
JAVA虚拟及规范中明确说明,尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾回收或进行压缩。但对于HotSpotJVM 方法区也称做非堆。
- 方法区(Method area)与Java堆一样,是各个线程共享的内存区域
- 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆一样都是可不连续的
- 方法区的大小和堆空间一样,可以设置为固定值或可扩展
- 方法区的大小决定了可以加载多少个类,如果系统定义了太多的类或动态代理,那么将会发生 OutOfMemory:PermGen(1.7)、OutOfMemory:MateSpace(1.8与之后),1.8开始方法区改为元空间
方法区演进
- 在JDK1.7之前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。
- 本质上,方法区和永久代并不等价。JVM虚拟机规范对如何实现方法区,不做统一要求。例如:EBA JRokit/IBM j9都不存在永久代的概念(现在看来,当年使用永久代,不是好的idea。导致Java程序更容易OOM,因为使用的是JVM内存(超过 -XX:MaxPermSize 上限))
- 而到了JDK8 ,终于完全废弃了永久代的概念,改用与JRokit,J9一样使用本地内存来实现元空间
- 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代使用的最大区别在于元空间直接使用本地内存。
jdk7及之前:
通过-XX:PermSize来设置永久代出是分配空间,默认为20.75mb。-XX:MaxPermSize来设定永久代最大可分配空间。32位机器默认64M,64位机器模式是82M
jdk8之后:
元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize指定,替代上述原因两个参数。
默认值依赖与平台。windows下,-XX:MetaspaceSize是21M, -XX:MaxMatespaceSize的值是-1,没有上限
与永久代不同,如果不指定元空间的大小,默认情况下,虚拟机会耗尽所有系统内存,最后抛出 OutOfMemoryError
-XX:MetaspaceSize: 设置初始的元空间大小。对于一个64位的服务器端来说,其默认的-XX:MetaspaceSize=21M。默认的方法区初始大小。在方法区空间不足时,会触发FullGC尝试卸载无用的类,还是无可用内存的话,将会触发Full GC。如果释放的空间不足,那么在不超过MaxMetaspaceSize 时,适当提高该值。(如果MetaspaceSize 初始设置过小可能频繁触发FullGC)
jdk版本 | 方法区变化 |
jdk1.6及以前 | 有永久代(permanent generation),静态变量存放在永久代上 |
jdk1.7 | 有永久代,但已经逐步去永久代,字符串常量池,静态变量移除保存在堆中 |
jdk.18 | 无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池,静态变量任然在堆中 |
方法区为什么要调整为使用本地内存?
对方法区进行调优是很困难的类的回收条件非常苛刻,一些项目会动态加载很多类,很可能会内存溢出倒不如直接使用本地内存
StringTable为什么要调整?
永久代的回收效率低,只有在老年代不足或方法区不足触发FullGC的时候在进行回收,而开发过程中有大量的字符串被创建,回收效率低,导致永久代空间不足。放到堆里,能及时回收内存。
方法区的内部结构
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即使编译器编译后的代码缓存、域信息、方法信息(域信息,方法信息也可看做包含在类型信息中)。
类型信息
对每个加载的类型(类Class、接口Interface、枚举Enum、注解Annotation),JVM必须在方法区中存储一下类型信息:
- 这个类的完整有效包名(全类名)
- 这个类型的直接父类的完整有效名(对于interface或java.lang.Object 没有父类)
- 这个类型的修饰符(public ,abstract,final的某个子集)
- 这个类型直接接口的一个有序列表
域(Field)信息
- JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序(属性)
- 域相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient的某个子集)
方法信息
JVM必须保存所有方法的一下信息,同域信息一样包括生命顺序
- 方法名称
- 方法返回类型(或 void)
- 方法参数数量和类型(按声明顺序)
- 方法的修饰符(public、private、protected、static、final、synchronized、native、abstract 的某个子集)
- 方法字节码信息、操作数栈、局部变量表大小(abstract、native除外)
- 异常表,每个异常处理的开始位置,结束位置,代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
Classfile /E:/javaSource/jvm-demo/target/classes/com/tttiger/MethodAreaTest.class
Last modified 2020-8-22; size 1300 bytes
MD5 checksum 2a7cbd3d6b78efd7fda7e3bdea57b838
Compiled from "MethodAreaTest.java"
// 类型全类名,父类,接口,泛型全类名
public class com.tttiger.MethodAreaTest extends java.lang.Object implements java.lang.Comparable<java.lang.String>
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
// 常量池
Constant pool:
#1 = Methodref #12.#45 // java/lang/Object."<init>":()V
#2 = Fieldref #46.#47 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #48.#49 // java/io/PrintStream.println:(I)V
#4 = Class #50 // java/lang/Exception
#5 = Methodref #4.#51 // java/lang/Exception.printStackTrace:()V
#6 = Class #52 // java/lang/String
#7 = Methodref #11.#53 // com/tttiger/MethodAreaTest.compareTo:(Ljava/lang/String;)I
#8 = Fieldref #11.#54 // com/tttiger/MethodAreaTest.num:I
#9 = String #55 // 测试方法区
#10 = Fieldref #11.#56 // com/tttiger/MethodAreaTest.str:Ljava/lang/String;
#11 = Class #57 // com/tttiger/MethodAreaTest
#12 = Class #58 // java/lang/Object
#13 = Class #59 // java/lang/Comparable
#14 = Utf8 num
#15 = Utf8 I
#16 = Utf8 str
#17 = Utf8 Ljava/lang/String;
#18 = Utf8 <init>
#19 = Utf8 ()V
#20 = Utf8 Code
#21 = Utf8 LineNumberTable
#22 = Utf8 LocalVariableTable
#23 = Utf8 this
#24 = Utf8 Lcom/tttiger/MethodAreaTest;
#25 = Utf8 test1
#26 = Utf8 count
#27 = Utf8 test2
#28 = Utf8 (I)I
#29 = Utf8 value
#30 = Utf8 e
#31 = Utf8 Ljava/lang/Exception;
#32 = Utf8 cal
#33 = Utf8 result
#34 = Utf8 StackMapTable
#35 = Class #50 // java/lang/Exception
#36 = Utf8 compareTo
#37 = Utf8 (Ljava/lang/String;)I
#38 = Utf8 o
#39 = Utf8 (Ljava/lang/Object;)I
#40 = Utf8 <clinit>
#41 = Utf8 Signature
#42 = Utf8 Ljava/lang/Object;Ljava/lang/Comparable<Ljava/lang/String;>;
#43 = Utf8 SourceFile
#44 = Utf8 MethodAreaTest.java
#45 = NameAndType #18:#19 // "<init>":()V
#46 = Class #60 // java/lang/System
#47 = NameAndType #61:#62 // out:Ljava/io/PrintStream;
#48 = Class #63 // java/io/PrintStream
#49 = NameAndType #64:#65 // println:(I)V
#50 = Utf8 java/lang/Exception
#51 = NameAndType #66:#19 // printStackTrace:()V
#52 = Utf8 java/lang/String
#53 = NameAndType #36:#37 // compareTo:(Ljava/lang/String;)I
#54 = NameAndType #14:#15 // num:I
#55 = Utf8 测试方法区
#56 = NameAndType #16:#17 // str:Ljava/lang/String;
#57 = Utf8 com/tttiger/MethodAreaTest
#58 = Utf8 java/lang/Object
#59 = Utf8 java/lang/Comparable
#60 = Utf8 java/lang/System
#61 = Utf8 out
#62 = Utf8 Ljava/io/PrintStream;
#63 = Utf8 java/io/PrintStream
#64 = Utf8 println
#65 = Utf8 (I)V
#66 = Utf8 printStackTrace
{
// 域信息
// 属性类型描述访问修饰符
public static int num;
// 类型描述
descriptor: I
// 访问修饰
flags: ACC_PUBLIC, ACC_STATIC
private static java.lang.String str;
descriptor: Ljava/lang/String;
flags: ACC_PRIVATE, ACC_STATIC
// 默认构造器
public com.tttiger.MethodAreaTest();
// 返回信息
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 6: 0
// 局部变量表信息
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/tttiger/MethodAreaTest;
// 方法信息
public void test1();
// 返回类型
descriptor: ()V
// 访问修饰
flags: ACC_PUBLIC
// 字节码
Code:
stack=2, locals=2, args_size=1
0: bipush 20
2: istore_1
3: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
6: iload_1
7: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
10: return
// 行号对应指令的表
LineNumberTable:
line 13: 0
line 14: 3
line 15: 10
// 局部变量表
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/tttiger/MethodAreaTest;
3 8 1 count I
public static int test2(int);
descriptor: (I)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 30
4: istore_2
5: iload_2
6: iload_0
7: idiv
8: istore_1
9: goto 17
12: astore_2
13: aload_2
14: invokevirtual #5 // Method java/lang/Exception.printStackTrace:()V
17: iload_1
18: ireturn
// 异常表
Exception table:
from to target type
2 9 12 Class java/lang/Exception
LineNumberTable:
line 18: 0
line 21: 2
line 22: 5
line 25: 9
line 23: 12
line 24: 13
line 26: 17
LocalVariableTable:
Start Length Slot Name Signature
5 4 2 value I
13 4 2 e Ljava/lang/Exception;
0 19 0 cal I
2 17 1 result I
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 12
locals = [ int, int ]
stack = [ class java/lang/Exception ]
frame_type = 4 /* same */
public int compareTo(java.lang.String);
descriptor: (Ljava/lang/String;)I
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=2
0: iconst_0
1: ireturn
LineNumberTable:
line 31: 0
LocalVariableTable:
Start Length Slot Name Signature
0 2 0 this Lcom/tttiger/MethodAreaTest;
0 2 1 o Ljava/lang/String;
public int compareTo(java.lang.Object);
descriptor: (Ljava/lang/Object;)I
flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: checkcast #6 // class java/lang/String
5: invokevirtual #7 // Method compareTo:(Ljava/lang/String;)I
8: ireturn
LineNumberTable:
line 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lcom/tttiger/MethodAreaTest;
// 静态初始化clint方法,初始化赋值static属性整合static代码块
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: bipush 10
2: putstatic #8 // Field num:I
5: ldc #9 // String 测试方法区
7: putstatic #10 // Field str:Ljava/lang/String;
10: return
LineNumberTable:
line 8: 0
line 9: 5
}
Signature: #42 // Ljava/lang/Object;Ljava/lang/Comparable<Ljava/lang/String;>;
SourceFile: "MethodAreaTest.java"
static 与 static final 区别
// 普通static在链接准备时进行初始化,赋值默认0值,然后再初始化阶段赋予实际值(clint方法中赋值)
public static int num;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC
// static final 方法在编译器就指定了值
private static final int num2;
descriptor: I
flags: ACC_PRIVATE, ACC_STATIC, ACC_FINAL
// 直接指定值
ConstantValue: int 20
// clint 方法中对num进行实际赋值
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: bipush 10
2: putstatic #8 // Field num:I
5: return
LineNumberTable:
line 8: 0
运行时常量池
- 方法区中包含了运行时常量池
- 字节码文件中包含了常量池
- 常量池表是class文件的一部分,用于存放编译器生成的各种字面量与符号引用,这部分内容在类加载后存放到方法区的运行时常量池中
- 运行时常量池,在加载类接口到虚拟机后,就会创建对应的运行时常量池
- JMV为每个加载的类型都维护一个常量池。池中的数据项像数组一样通过索引访问
- 运行时常量池中包含多种不同的常量,包括编译期就明确的数值字面量,也包括需要解析后才能获得的方法或字段引用,解析后运行时常量池中的引用解析为运行时真是的内存地址
#3 = Methodref #48.#49 // java/io/PrintStream.println:(I)V
invokevirtual #3
class文件中方法调用,使用#3 符号殷弘标识常量池中的调用,(这都只是字面量,符号用于描述程序调用)虚拟机加载时将会替换成真是的符号所代表的描述的类或方法或字面量的真实地址(类似符号表)
方法区的垃圾回收
方法区的回收效率不高,尤其是类型的卸载,条件很苛刻。但这个区域的回收有是有却也是必要的。(JDK 11 ZGC收集器不再支持类卸载)
方法区的垃圾回收主要回收两部分内容:常量池中废弃的常量和不再使用的类型
常量回收
- 方法区的常量池主要存放两大类信息:字面量和符号引用。字面量比较接近java语言层次的常量概念,如文本字符串,被声明为final的常量值等。而符号引用属于编译原理的概念,包括下面三类常量
- 类和接口的全限定名
- 字段的名称和表述符
- 方法的名称和描述符
- HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收
类回收
判断一个class是否可以被回收条件是很苛刻的,需要同时满足三个条件
- 该类的所有实例回收,也就是堆中不存在任何该类及其子类实例
- 加载类的垃圾回收器被回收,这个条件是很难达成的除非精心的可替换类加载器的场景,如JSP,OSGI等大量动态生成类的场景
- 该类对应的java.lang.Class对象没有再任何地方被引用,无法再任何地方通过反射访问该类的方法
java虚拟机允许满足了上面的三个条件的类被回收,被允许不是必然,是否可以回收java虚拟机提供了 -Xnclassgc 控制
类的实例化
类的实例化的几种方式
- new
- Class的newInstance()
- Constractor的newInstance(Xxx)
- 使用Clone
- 使用反序列化
- 使用第三方字节码生成库
// Object o = new Object();
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1
new一个对象对应到字节码层面就是,
使用new关键字 #2 (Object的标识符),Object类型加载,分配堆空间(类实例的占用空间是可以确定的),进行“零”值初始化。new字节码指令的作用是创建指定类型的对象实例、对其进行默认初始化,并且将指向该实例的一个引用压入操作数栈顶;
使用dup指令将new时分配的对象引用在复制一份,因为在下面调用invokespecical调用构造器会pop栈中的对象引用作为this传递,复制一份是为了astore 存储到局部变量表
对象的创建步骤
创建对象的执行步骤
- 判断对象的类是否加载。加载、链接、初始化。(当虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载、解析和初始化。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为key进行查找对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象
- 为对象分配内存。(首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用空间即可,即4字节)
- 如果内存规整。(如果内存是规整的虚拟机采用指针碰撞算法(Bump the pointer)来为对象分配内存。意思是所有用过的内存在一边,空闲的内存在一边,用指针标识分界点。分配内存就行将指针向空闲方移动指定的距离。如果垃圾回收器选择的是Serical,ParNew这种基于压缩算法的,带有整理压缩功能的,虚拟机采用指针碰撞分配内存)标记整理
- 如果内存不规整。(如果内存是不规整的,使用的与空闲的内存是相互交错的,那么虚拟机采用空闲列表来为对象分配内存。空闲列表就是虚拟机维护一个列表,记录那些内存块是空闲的,在分配内存时找一块足够大的空间进行分配,更新空闲列表。标记清除
- 并发安全处理
- 采用CAS重试,区域加锁保证更新的原子性
- 每个线程分配TLAB
- 初始化分配到的内存。(为属性设置默认值,保证对象实例属性在不赋值的情况下可用
- 设置对象头。(将对象的所属类(类元信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象头中、这个过程取决与JVM的实现)
- 执行init方法进行初始化。(初始化成员变量,执行实例代码块,调用构造方法,并将堆内对象的首地址赋值为引用变量)init方法就是将成员变量的直接赋值,实例代码块,构造器合并。
public class Consumer {
public int id = 888;
public String name;
public Account acct;
{
this.name = "匿名用户";
}
public Consumer(){
this.acct = new Account();
}
}
class Account{
}
public com.tttiger.Consumer();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: sipush 888
8: putfield #2 // Field id:I
11: aload_0
12: ldc #3 // String 匿名用户
14: putfield #4 // Field name:Ljava/lang/String;
17: aload_0
18: new #5 // class com/tttiger/Account
21: dup
22: invokespecial #6 // Method com/tttiger/Account."<init>":()V
25: putfield #7 // Field acct:Lcom/tttiger/Account;
28: return
LineNumberTable:
line 11: 0
line 4: 4
line 9: 11
line 12: 17
line 13: 28
LocalVariableTable:
Start Length Slot Name Signature
0 29 0 this Lcom/tttiger/Consumer;
对象的内存布局
public class Consumer {
public int id = 888;
public String name;
public Account acct;
{
this.name = "匿名用户";
}
public Consumer(){
this.acct = new Account();
}
public static void main(String[] args) {
Consumer con = new Consumer();
}
}
class Account{
}
执行引擎
- “虚拟机” 是一个相对于“物理机”的概念,这两种机器都有代码执行能力,区别在于物理机的执行引擎是直接建立在、处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎是由软件实现的,因此可以不受物理条件制约的定制指令集与执行引擎,能够执行那些不被硬件直接支持的指令
- 字节码文件并不能直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令,符号表,以及其他辅助信息。如果想要运行Java程序,执行引擎(Execution Engine)的任务就说将字节码指令/编译为平台上的本地机器指令运行
- 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器(程序计数器)
- 每当执行完一项指令操作后,PC寄存器就会更新下一个需要被执行的指令地址。
- 当方法在执行的过程中,执行引擎可以通过局部变量表中的引用定位到堆中的对象实例,通过对象实例的头中的类型引用可以定位到类元信息
- 执行引擎就是将程序计数器执行的字节码指令翻译为机器指令执行
解释器
当java虚拟机启动时会根据规范定义对字节码逐行解释执行,将字节码翻译为平台对应的本地机器指令
- 解释器的真正意义上所承担的角色就是一个运行时"翻译者",将字节码文件中的内容“翻译”为平台对应的机器指令执行
- 当一条字节码指令被解释执行完成后,接着根据pc寄存器中记录的下一条需要被执行的字节码指令执行解释执行
JIT(即时编译器)
将源代码(字节码)直接编译和本地机器相关的机器语言。
- 解释器解释执行一直被认为是低效的代名词,为了解决这个问题,java平台支持一种叫做JIT即时编译技术,即时编译技术的目的就是避免函数被解释执行,而是将整个函数体编译为机器码,每次函数执行时,只执行被编译后的机器码即可
java语言为半编译半解释型语言主要原因就在于在执行代码有两种方式字节码解释器和JIT编译器
为什么采用半编译半解释
解释器优势
当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即响应执行。但解释执行的效率较低。
编译器优势
编译器想要发挥作用,把代码编译为本地机器码,需要一定的执行时间。但编译为本地代码后执行速度快。
当虚拟机启动的时候,解释器可以首先发挥作用,不必等待编译器全部编译完成,这样可以省去编译的时间快速启动程序。随着程序的运行,编译器逐渐发挥作用,查找热点代码,将有价值的代码编译为本地机器指令。
何时JIT
JIT编译器在运行时针对那些频繁被调用的“热点代码”做出深度优化。
- 一个被多次调用的方法,或是一个方法内部循环次数较多的循环体都可以被称之为热点代码,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生再方法执行过程中,因此也被称之为栈上替换(和栈上分配是两个东西),或简称为OSR(on stack replacement) 参考
- 热点探测采用计数器方法对方法调用次数、循环体执行次数进行技术,分别为方法调用计数器(invocation counter) 和回边计数器(back edge counter 统计循环次数)(JVM Clinet模式1500次,Server模式下10000次。超过次数会触发JIT)
-XX:CompileThreshold来认为设定 - 当一个方法被调用时,会检查该方法是否存在JIT编译后的代码,如果存在,则优先使用编译后的本地代码来执行。如果不存在编译后的版本,则将此方法的调用计数器+1,然后判断方法调用计数器和回边计数器之和是否超过方法调用计数器的阈值。如果超过JIT进行异步编译,编译完成后,栈上替换技术支持当前方法从解释执行切换为执行编译后的代码
热度衰减
方法调用计数并不是一直累加的。当超过一定时间限度,如果方法的调用次数还没有超过阈值,那么这个方法的计数就会衰减一半,这个过程称为热度衰减。-XX:-UseCounterDecay 关闭热度衰减这样程序运行时间长后都可以编译为本地代码。-XX:CounterHalfLifeTime 设置半衰时间单位秒。(热度衰减的动作是在虚拟机进行垃圾回收时顺便进行的)
执行引擎设置
-Xint 完全采用解释执行模式
-Xcomp 完全采用即时编译器执行程序。(有问题会再尝试解释器)
-Xmixed 混合模式
JVM有实际上由两种即时编译器分别对应JVM的Client模式与Server模式(64位虚拟机默认使用Server模式)简称C1,C2
java -client 、java -server 运行程序指定是使用C1编译器还是C2编辑器。C1编译器会对字节码简单和可靠的优化,耗时短。可以更快的编译。C2会进行耗时较长的深度的优化,但编译后机器码执行效率高。
String
String 的基本特性
- String表示字符串,使用一堆“”引起来表示
- String被声明位fianl的,不可被继承
- String实现了Serializable接口,表示可序列化;实现了Comparable接口,表示可比较
- String在jdk8及以前内部定义位char[] 使用字符数组存储,jdk9时改为byte[] 使用字节数组存储(统计发现大部分字符串对象都是拉丁文只需要一个字节就能存储,char使用两个字节这样就浪费了很多空间,改为使用bate[]存储,基于Stirng 的StringBuffer和StringBuilder都做了改动)
- String的不可变性
- 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。
- 当对现有的字符串进行连接操作时,可需要重新指定内存区域赋值,不能使用原有的value进行赋值。
- 当调用String的replace()方法修改字符串时,也需要重新指定内存区域进行赋值,不能使用原有的value进行赋值。
- 字符串常量池中不会存储相同内容的字符串
- String 的 String Pool 是一个固定大小的Hashtable,默认值大小长度是1009(jdk6)。如果放进String Pool的String非常多,就会造成Hash冲突,从而导致链表变长,而链表长了后直接会造成影响的是String.intern时的性能。
- jdk7String Pool的默认长度变成60013,jdk8开始1009是可设置的最小值(使用 -XX:StringTableSize设置)
String 内存分配
- 直接使用双引号声明出来的对象会直接存储在常量值中。注意是代码执行时,第一次使用”abc"时,常量池中没有,才会将字符串常量放入常量池s
String info = "abc";
- 如果不是用双引号声明的String对象,可以使用String提供的intern() 方法
- java6以前,字符串常量池存放在永久代中。java7将字符串常量池移动到堆中。(所有的字符串对象都保存在堆中,和其他普通对象一样,这样更方便调优)java8虽然永久代改为元空间,字符串常量池还是在堆中。
String的拼接
- 常量与常量的拼接结果在常量值中,编译期会进行优化
// 与 String abc = "abc"; 是相同的
String abc = "a"+"b"+"c";
- 只要其中又一个变量,拼接的结果在堆中。变量的拼接底层是用StringBuilder实现的
public void newString
// 常量池中
String a = "a";
// 常量池中
String b = "b";
// 堆中
String abc = a+b+"c";
}
生成的字节码
// 加载常量池中a的引用入栈
0 ldc #2 <a>
// 存储在局部变量表1的位置(0的位置是this)
2 astore_1
// 加载常量池中b的引用入栈
3 ldc #3 <b>
// 存储在局部变量表2的位置(变量b对应的位置)
5 astore_2
// 开始拼接字符串,首先new了一个StringBuilder,分配内存,默认初始化
6 new #4 <java/lang/StringBuilder>
// 赋值栈顶引用(invokespecial会用掉一个引用,dup后栈顶又两个对象的引用)
9 dup
// 调用构造方法
10 invokespecial #5 <java/lang/StringBuilder.<init>>
// 加载局部变量1位置,也就是a
13 aload_1
// 调用StringBuilder的append拼接参数
14 invokevirtual #6 <java/lang/StringBuilder.append>
// 加载局部变量2位置,也就是b
17 aload_2
// 调用StringBuilder的append拼接参数
18 invokevirtual #6 <java/lang/StringBuilder.append>
// 加载常量池中的c
21 ldc #7 <c>
// 调用StringBuilder的append拼接参数
23 invokevirtual #6 <java/lang/StringBuilder.append>
// 调用StringBuilder的toString方法,返回String类型
26 invokevirtual #8 <java/lang/StringBuilder.toString>
// 存储在局部变量表3的位置(abc)
29 astore_3
30 return
// StringBuilder的toString
public String toString() {
// 动态拼接的变量存储在堆中
return new String(this.value, 0, this.count);
}
// 所以 ab == "ab" 结果是false,一个在堆中一个在常量池中
public void testString(){
String a = "a";
String b = "b";
String ab = a+b;
System.out.println(ab == "ab");
}
// 如果变量都被声明为final那么编译时也会进行优化,会把a+b的结果给常量池放一份直接赋值给变量ab
public void newString(){
final String a = "a";
final String b = "b";
String ab = a+b;
System.out.println(ab == "ab");
}
String的intern()方法
- 如果一个String a 调用intern()方法时,如果常量池中已经又一个实例和 a 用 equals 比较是相等的,那么intern返回常量池中实例的引用
- 如果一个String a 调用intern()方法时,如果常量池中没有和 a 相等的字符串,那么将 a 添加到常量池中,返回常量池中的引用
- 如果s.intern() == t.intern() 当且仅当 s.equals(t)
String abc = "abc";
String abc2 = new String("abc");
System.out.println(abc == abc2);
System.out.println(abc == abc2.intern());
---sout
false
true
---字节码
0 ldc #12 <abc>
2 astore_1
3 new #13 <java/lang/String>
6 dup
7 ldc #12 <abc>
9 invokespecial #14 <java/lang/String.<init>>
12 astore_2
// 一个是直接加载常量池引用,一个是new Stirng 构造器传入常量池引用,abc2在堆中,abc在常量池中
深入理解intern方法
intern方法在1.6之后机制有所不同,在jdk1.6之后字符串常量池从方法区放在了堆中,字符串的intern也有所不同
String a = new String("哈")+new String("哈");
a.intern();
String b = "哈哈";
System.out.println(a == b);
// 1.6 版本 false
// 1.6 之后版本 true
直接来看下字节码
0 new #8 <java/lang/StringBuilder>
3 dup
4 invokespecial #9 <java/lang/StringBuilder.<init>>
7 new #2 <java/lang/String>
10 dup
11 ldc #10 <哈>
13 invokespecial #4 <java/lang/String.<init>>
16 invokevirtual #11 <java/lang/StringBuilder.append>
19 new #2 <java/lang/String>
22 dup
23 ldc #10 <哈>
25 invokespecial #4 <java/lang/String.<init>>
28 invokevirtual #11 <java/lang/StringBuilder.append>
31 invokevirtual #12 <java/lang/StringBuilder.toString>
34 astore_1 // 字符串拼接完成,此时字符串常量池中只有 “哈”
35 aload_1
36 invokevirtual #5 <java/lang/String.intern> // 将拼接结果 “哈哈” 放入常量池
39 pop
40 ldc #13 <哈哈> // 从常量池找 “哈哈”
42 astore_2 // 赋值给 b 变量
43 getstatic #6 <java/lang/System.out>
46 aload_1
47 aload_2
48 if_acmpne 55 (+7)
51 iconst_1
52 goto 56 (+4)
55 iconst_0
56 invokevirtual #7 <java/io/PrintStream.println>
59 return
再看这段代码
String a = new String("哈")+new String("哈");
// 返回常量池中的引用
String c = a.intern();
String b = "哈哈"; // 去常量池找"哈哈",没有常量池放入"哈哈”,但是a.intern()已经放入"哈哈"
System.out.println(a == b);
System.out.println(a == c);
// 1.6 false false
// 1.6 之后版本 true true
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a6cAQzJH-1618211668116)(https://gitee.com/diaosinanshen/picgo/raw/master/img/StringTable (1)].jpg)
在jdk6之后使用String的intern方法,如果字符串常量池中有则返回常量池引用,如果没有在常量池中直接保存调用对象的引用。
在 JDK1.6下 a.intern() 时字符串常量池中还没有“哈哈”,a.intern() 调用后给常量池放入了“哈哈”,返回常量池中“哈哈”的引用,这与之前堆中的a指向的堆中的对象没有任何关系。
在 JDK 1.7/8 下 a.intern() 时字符串常量池中还没有“哈哈”,a.intern() 将自身的引用放入字符串常量池,返回常量池中“哈哈”的引用,这与a指向的是堆中的同一个对象。
总结
jdk 1.6中,调用String的intern方法
- 如果字符串池中有,则不会放入。返回字符串池中已有对象的地址
- 如果没有,将此对象复制一份,放入字符串池,并返回字符串池中对象地址
jdk 1.7/8中,调用String的intern方法
- 如果字符串池中有,则不会放入。返回字符串池中已有对象的地址
- 如果么有,将对象的引用复制一份,放入字符串池,并返回字符串池中的引用
垃圾回收
概述
垃圾回收机制是Java的招牌能力,极大提高开发效率,如今垃圾回收几乎成为现代语言的标配。
垃圾是指在程序运行期间没有任何指针指向的对象,这个对象占用的内存就需要被回收。如果不即时堆内存中的垃圾进行清理,那么,这些垃圾占用的内存会一直保留到应用进程结束,垃圾占用的内存无法被其他内存使用,甚至因此导致内存溢出。
垃圾标记算法
在GC执行垃圾回收之前,首先需要确定内存中那些对象“存活”,那么对象“死亡”。已有被标记为已"死亡"的对象,GC才会对其进行会后。这个确定对象是否存活的阶段称为标记阶段。(当一个对象已经不再被任何存活的对象继续引用时,就被当作“死亡”对象)
引用计数算法
引用计数算法就说为每一个对象保存一个计数属性,用来记录对象的引用次数,只要有任何对象引用了此对象引用次数加一,当引用失效时计数器减一。这种方法虽然实现简单,对象辨识度高,回收效率告,但它有着致命的缺点,即无法解决循环引用的问题。
可达性分析算法
相对于引用计数算法,可达性分析算法不仅同样具备实现简单和执行效率高等特点,更重要的是该算法可以有效的解决循环引用的问题,防止内存泄漏。Java垃圾的标记就使用可达性分析算法 。
- 可达性分析是一以跟对象集合为起点,按照从上至下的方式搜索被根对象集合所连接的目标对象。
- 使用可达性分析后,内存中存活的对象都会被根对象直接或间接的引用着,搜索走过的路径称为“引用链”,如果对象没有被引用链连接,那么对象就是不可达的,就是垃圾。
可以作为GCRoots的引用
- 栈中对对象的引用
- 方法区中静态属性对对象的引用
- 方法区中常量引用的对象(字符串常量池)
- 被synchronized同步锁定的对象
在执行可达性分析时,分析工作必须在一个保证一致性的内存快照中,这点如果不满足那么分析结果就不是准确的,这点也是导致GC必须进行Stop the world 的原因,枚举根节点必须停止
对象Finalization机制
当垃圾回收器发现没有引用指向一个对象,这个对象就是垃圾,在回收它之前,总会先调用这个对象的finalize()方法。
finalize()方法允许在子类中重写,常用于在对象回收时进行一些资源释放。GC会判断该对象是否覆盖了finalize方法,若未覆盖,则直接将其回收。否则,若对象未执行过finalize方法,将其放入F-Queue队列,由一低优先级线程执行该队列中对象的finalize方法。执行finalize方法完毕后,GC会再次判断该对象是否可达,若不可达,则进行回收,否则,对象“复活”。
对象可由两种状态,涉及到两类状态空间,一是终结状态空间 F = {unfinalized, finalizable, finalized};二是可达状态空间 R = {reachable, finalizer-reachable, unreachable}。各状态含义如下:
unfinalized: 新建对象会先进入此状态,GC并未准备执行其finalize方法,因为该对象是可达的
finalizable: 表示GC可对该对象执行finalize方法,GC已检测到该对象不可达。正如前面所述,GC通过F-Queue队列和一专用线程完成finalize的执行
finalized: 表示GC已经对该对象执行过finalize方法
reachable: 表示GC Roots引用可达
finalizer-reachable(f-reachable):表示不是reachable,但可通过某个finalizable对象可达
unreachable:对象不可通过上面两种途径可达
垃圾清除算法
当成功区分出内存中存活对象与死亡对象时,GC接下来的任务就是执行垃圾回收,释放掉死亡对象占用的空间,以便于接下来生成的对象内存分配。
目前在JVM中常见的垃圾清除算法可分为三种
- 标记清除算法
- 复制算法
- 标记压缩算法
标记清除
- 标记:Collector从根节点开始遍历,标记所有被引用的对象。一般是在对象Header中标记为可达
- 清除:Collector对堆内存从头到尾遍历,如果发现某个对象在其Header中标记为不可达,则将其回收
缺点:
1. 效率不高
2. 在进行GC的时候,需要停止整个应用程序
3. 清理会产生内存碎片,需要维护空闲列表
注意,这里的清除不是真的置空,而是将需要清除的对象占用的内存地址保存到空闲列表中
复制算法
复制算法将内存分为两个区域AB使用(A大小=B大小),每次只使用其中一个区域,若当前使用A区域,在进行垃圾回收时就是将存活的对象复制到B区域,然后清空A区域,接下来使用B区域,如此往复。
- 清除:(如内存分为AB两个区域,当前正使用A区域)Collector从根节点开始遍历A区域,存活的对象全部被复制到B区域,清空整个A区域。
优点
- 没有标记和清除过程,实现简单,运行高效。
- 复制以后保证空间连续,不会出现内存碎片问题。
缺点
- 需要两倍内存空间
- 对于G1这种拆分为大量region的GC,复制而不是移动,意味着GC需要维护region与对象之间的引用关系(对象内存地址变了)
- 存活对象多的情况下效率不高
标记压缩算法
背景:复制算法的高效是建立在存活对象少,垃圾对象多的前提下。这种情况在新生代经常发生,但是在老年代,大部分都是存活的对象,如果继续使用复制算法,由于存活对象过多,复制的成本也将很高。
- 标记:Collector从根节点开始遍历,标记所有被引用的对象。一般是在对象Header中标记为可达
- 压缩:将所有存活的对象移动到内存的一端
- 清理:清理边界外的所有空间(维护的指针)
优点
- 没有内存碎片
- 无需维护空闲列表(只需维护一个指针,指向内存起始地址)
缺点
- 整理移动后的对象也需要维护引用关系
- 移动过程中,需要停止全部用户线程(STW)
分代收集
每种算法都有自己的优缺点,分代收集算法是基于这样一个事实:不用的对象生命周期是不一样的,因此不同生命周期的对象可以采用不同的收集方式。一般Java中将堆内存分为新生代和老年代,这个可以根据不同的年龄代的特点使用不同的回收算法,以提高垃圾回收的效率。
年轻代,老年代 1:2;年轻代eden,s0,s1,8:1:1 (JVM默认分代比例)
- 年轻代
- 年轻代区域相对较小,对象生命周期短,存活率低,回收频繁,这种情况下复制算法的回收最快,复制算法的内存利用率不高,hotspot虚拟机通过两个survival0,survival1的设计缓解空间浪费的情况
- 老年代
- 老年代区域相对较大,对象生命周期长,存活率高,回收不及年轻代频繁。这种情况下,使用标记清除或标记压缩算法比较合适(一般都使用两种算法混合实现),以hotspot中的CMS收集器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率高。而对于碎片问题,CMS采用基于Mark-Compact的Serial Old回收器作为补偿,当内存回收不佳(碎片导致的Concurrent Mold Failuer时),将采用Serial Old执行Full GC以达到堆老年代的整理。
增量收集
如果一次垃圾回收的时间过长,那么可以让垃圾回收线程与用户线程交替执行。每次垃圾回收器只收集一小片区域的内存空间,然后切换到用户线程。如此反复直到垃圾回收完成。增量收集本质上还是标记清除,标记整理算法,他是通过对线程冲突的妥善处理,允许垃圾回收期分阶段的完成标记、清除、整理或复制工作。(例如CMS回收器)
分区收集
一般来说,相同条件下,堆空间越大,一次GC所需要的时间就越长,有关GC产生的停顿也越长。为了更好的控制GC的停顿时间,将一块大的内存分为成多个小块,根据需要的停顿时间,每次合理的回收若干个小区间,而不是回收整个内存,从而减少GC停顿时间。(G1回收器)
Stop The World
StopTheWorld,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用线程被暂停,没有任何相应,这个停顿被称为StopTheWorld。
可达性分析算法中枚举根节点的过程会导致Java执行线程停顿。
- 分析工作必须在一个能确保一致性的快照中进行
- 一致性指的是分析期间整个系统看起来像是冻结在某个时间点
- 如果分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
OOMAP
oop (ordinary object pointer) 普通对象指针,oopmap就是存放这些指针的map,OopMap 用于枚举 GC Roots,记录栈中引用数据类型的位置。迄今为止,所有收集器在根节点枚举这一步骤都是必须暂停用户线程的,
收集线程会对栈上的内存进行扫描,看看哪些位置存储了Reference类型。如果发现某个位置确实存的是Reference类型,它所引用的对象这一次不能被回收。问题是,栈上的本地变量表里面只有一部分数据是Reference类型的,那些非Reference类型的数据对我们而言毫无用途,但我们还是不得不堆整个栈全部扫描一遍,这是对时间和资源的一种浪费。
一个很自然的想法时,能不能用空间换时间,把栈上代表的引用的位置全部记录下来,这样到真正gc的时候就可以直接读取,而不用再一点一点的扫描了,Hotspot就是实现的。它使用一种叫做OopMak的数据结构来记录这类信息。
一个线程为一个栈,一个栈由多个栈桢组成,一个栈桢对应一个方法,一个方法有多个安全点。GC发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的OopMap,记录栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈桢的OopMap ,通过栈中记录的被引用的对象内存地址,即可找到这些对象(GC Roots)
总结oopMap的作用
- 可以避免全栈扫描,加快枚举根节点的速度
- 可以帮助HotSpot实现准确式GC
安全点安全区域
安全点
程序并不是在所有地方都会停下来进行GC,只有在特定的位置才会停下来GC,这些位置就称为安全点。
安全点的选择很重要,如果太少可能导致等待GC时间太长,如果太多可能导致GC太频繁。大部分的执行时间都非常短暂,通常会根据是否语句让程序长时间执行的特征为标准,比如选择一个执行时间较长的指令作为SafePoint,如方法调用,循环跳转和异常跳转。
中断方式
- 抢先式中断:(目前没有虚拟机采用)首先中断所有线程。如果还有线程不在安全点,就恢复线程,让程序跑到安全点
- 主动式中断:设置一个中断标识,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断为真,则将自己进行中断。
- 如果需要GC设置标志为true,各线程运行到安全点,查看标识为真,表示需要进行GC则中断自己
安全区域
SafePoint机制保证了程序执行时,在不太长时间就会遇到可进入GC的SafePoint。但是如果程序处于Sleep状态或Blocked状态,线程无法走到安全点去中断挂起,JVM也不可能等待线程被唤醒。这种情况就需要安全区域来解决。
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的
- 当线程运行到安全区域的代码时,首先会标识已经进去了安全区域,如果这段时间发生GC,JVM会忽略标识为Safe Region的线程
- 当线程离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续进行,否则线程必须等待直到收到可以离开Safe Region的信号为止。
性能指标
吞吐量
吞吐量代表着用户代码运行时间占总运行时间的占比。(总运行时间= 程序运行时间 + 垃圾回收时间)
暂停时间
执行垃圾收集时,程序被暂停的时间。(垃圾回收的频率与暂停时间存在相关关系,通常来说垃圾回收频率越低,程序暂停时间越长)
这两个指标也表示这垃圾回收器的两个发展方向。吞吐量优先,意味着在单位时间中,STW的时间最短,STW在总运行时间中的占比最少。暂停时间优先,意味着每次垃圾回收时暂停用户线程的时间最短,低延时。(暂停时间优先往往意味着回收频率的提高,总的停顿时间可能更长)
垃圾回收器
(基于JDK14最新的搭配使用方案)
7款经典的垃圾收集器
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器:Serial Old、Parallel Old、CMS
整堆收集器:CMS
- 两个收集器之间有连线,表示他们可以搭配使用。
- 其中Serial Old作为CMS出现Concurrent Mode Failure 失败后的备用收集方案
- (红色虚线)由于维护成本和兼容性测试,在JDK8的时候将Serial + CMS ParNew + Serial Old 废弃(JSP 173) 并在JDK9中彻底删除组合
- (绿色虚线)JDK14中弃用Parallel Scavenge + Serial Old 组合
- JDK14中删除CMS垃圾回收器
Serial 收集器
- Serial 是最基本的,历史最悠久的垃圾收集器。JDK1.3之前新生代的唯一选择。
- Serial收集器采用复制算法、串行回收(单线程)和STW机制的方式执行内存回收。
- 除了年轻代之外 ,Serial还提供老年代的版本Serial Old。Serial Old收集器采用标记压缩算法,串行回收和STW机制方式执行内存回收。
- Serial Old 是运行在Client模式下默认的老年代垃圾回收器
- Serial Old 在Server模式下主要有两个用途:1. 与新生代Parallel Scavenge配合使用。2. 作为老年代CMS收集器的后备垃圾收集方案
Serial 系列收集器在进行垃圾回收时,只会使用一个线程进行垃圾回收,且在垃圾回收期间必须暂停其他工作线程,Serial收集器实现简单在单CPU下回收效率也不错
-XX:+UseSerialGC 参数可以指定年轻代和老年代都使用串行收集器。Serial 与 Serial Old搭配使用
ParNew 收集器
- ParNew就说Serial的多线程版本,ParNew只能回收新生代。ParNew出了使用多线程进行垃圾回收外,与Serial并无区别同样采用复制算法和STW机制进行垃圾回收。
- ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器。
- 对于新生代来说垃圾回收更频繁,使用并行的方式可以提高效率。(在多CPU(多核也是多CPU)的情况下可以更充分的利用硬件资源,单如果在单CPU情况下收集效率不如Serial)
-XX:+UseParNewGC 指定新生代使用ParNewGC
-XX:ParallelGCThreads 执行回收线程数量,默认开启和CPU数量相同的线程数
Parallel 收集器
- Parallel Scavenge收集器同样采用复制算法、并行回收和STW机制。这样看来Parallel Scavenge收集器与ParNew并没有什么不同。Parallel Scavenge收集器目标是达到一个可控的吞吐量,它也被成为吞吐量优先收集器。
- 高吞吐量可以高效的利用CPU时间,尽快完成程序的运算任务,主要合适在后台运算而不是大多交互的任务。因此,常见在服务器环境中使用。例如,批处理,订单处理,科学计算应用程序。
- Parallel Scavenge在JDK1.6时提供了老年代版本 Parallel Scavenge Old收集器用于回收老年代。Parallel Scavenge Old 采用标记压缩算法,同样也采用并行回收和STW机制
- 有自动调节策略,会自动调整堆大小,以达到控制吞吐量
- 在JAVA8中是默认的垃圾收集器
-XX:+UseParallelGC 手动指定年轻代使用Parallel收集器
-XX:+UseParallelOldGC 手动指定老年代使用Parallel收集器,只要指定一个UseParallelGC或UseParallelOldGC另一个参数都会被激活配合使用
-XX:ParallelGCThreads 设置并行收集线程数,默认是CPU数量(当CPU小于8时),CPU大于8时线程数=3+[5*cpuCount/8]
-XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间(即STW时间,此参数的设置侧重暂停时间优先)。
- 为尽可能控制停顿时间在设置值,收集器会在工作时调整堆大小和其他参数
- 对于用户来说,侧重交互体验,停顿时间越短体验越好。但在服务器端,我们注重高并发,整体的吞吐量。所以服务器适合使用GCTimeRatio 控制
-XX:GCTimeRatio 垃圾收集时间占比,侧重吞吐量。取值范围(1-100),默认为99,也就是垃圾回收时间占比1%
-XX:+UseAdaptiveSizePolicy 设置开启Parallel 自适应调节策略,在这种模式下,年轻代,Eden和Survivor的比值、晋升老年代的对象年龄等参数会被自动调节,以达到在堆大小、吞吐量和停顿时间之间的平衡。默认是开启状态
CMS 收集器
在JDK1.5时期,HotSpot推出了一款在强交互应用中具有跨时代意义的垃圾收集器,CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作
CMS收集器的关注点是尽可能的缩短垃圾回收时暂停用户线程的时间。CMS采用标记清除算法,并且也会STW
- 初始标记:初始标记阶段,需要STW,这个阶段仅仅只是标记出GC Roots能直接关联的对象,所以标记非常快
- 并发标记:在这个阶段直接从GC Roots开始遍历整个对象图,耗时时间较长,不需要停止用户线程
- 重新标记:由于并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段要长一些
- 在并发标记过程成会中产生两种问题需要在重新标记阶段解决,1. 本来可达的对象不可达了(浮动垃圾)2. 本来不可达的内存可达了
- 浮动垃圾是可以容忍的,等待下一次回收
CMS采用标记清除算法,如果在并发标记阶段new了一个对象,但并发标记并没有从GC Roots找到该对象标记为可达,那么在清理过程中就会清理到可达对象,重新标记阶段就是修正这种错误。在并发清理阶段保证所有被标记为不可达的地址,都是真正不死亡对象。建议阅读
- 并发清理:清理删除掉标记为死亡的对象,是否内存空间。由于不需要移动存活对象,所以在这个阶段可以并发完成
由于最耗时的两个阶段并发标记与并发清除阶段都不需要停止用户线程,所以整体的回收是低停顿的。由于在垃圾收集阶段用户线程没有中断,所以在CMS的回收过程中,还要确保用户线程有足够的内存可用。因此CMS收集器不能等到老年代满了再回收,在达到设置阈值时就要提前进行垃圾回收。如果CMS运行期间内存不够用了,这是就会出现”Concurrent Mode Failure“这时虚拟机将会启动后备方案:临时启动 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就更长了。CMS由于使用标记清除,会产生内存碎片,当空闲空间不足时,不得不提前触发Full GC进行碎片整理
-XX:+UseConcMarkSweepGC 手动指定使用CMS 收集器执行内存回收任务。开启该参数后自动加 -XX:UseParNewGC打开。也就是说将会使用ParNew(Yound区)+ CMS(Old区)+Serial Old 后备的组合。
-XX:CMSInitiatingOcuupanyFraction 设置堆内存使用率的阈值,当达到这个阈值就触发垃圾回收。JDK5之前默认是68%,JDK6即以上版本默认是92%,此阈值的设置要根据程序使用内存的增长速度来适当设置,如果程序使用内存增长快而阈值又大很可能频繁触发FullGC
-XX:+UseCMSCompactAtFullCollection 用于指定执行完FullGC后对内存空间进行压缩整理,以此避免内存碎片。内存整理无法并发完成所以停顿时间将会加长。
-XX:CMSFullGCsBeforeCompaction 设置执行多少次FullGC后进行内存整理
-XX:ParallelCMSThreads 设置CMS 并发执行时线程数量,默认启动的线程数是(ParallelGCThreads+3)/4
在JDK9中CMS垃圾收集器已经被标记为废弃。在JDK14中已经删除CMS收集器,如强制指定会使用默认收集器
G1 收集器
官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,希望它担当起”全功能收集器“的重任。
G1是一个并行收集器,它把堆内存分成很多不相关的区域(Region)。使用不同的Region来表示Eden,幸存者0区,幸存者1区,老年代。G1跟踪各个Region里面垃圾堆积的价值大小(回收可以释放的空间大小和所需要的时间的经验)在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region,Garbage First。在JDK7中正式启用,是JDK9以后的默认收集器,(JDK9移除了CMS)
优势
- 并行与并发
- 并行性:G1再回收期间,可以又多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW(垃圾回收器的并行与并发是向对于用户线程与垃圾回收线程来说的)
- 并发行:G1拥有与用户程序交替执行的能力,部分工作可以和应用同时执行,一般来说,不会再整个阶段完全阻塞用户线程。(垃圾回收线程与用户线程交替执行)
- 分代收集
- G1依然属于分代型垃圾收集器,它会区分年轻代和老年代,年轻代依然后Eden与Survivor区。但从堆结构上看,它不要求整个Eden区,年轻代或老年代是连续的,也不再固定大小和固定数量。G1 将内存划分为一个个Region,这些Region再逻辑上组成了年轻代和老年代,G1的垃圾会后兼顾整个逻辑年轻代老年代。
- 空间整合
- G1将内存划分为一个个Region。内存的回收是以Region为单位的。Region之间是复制算法,但整体上可以看作是标记压缩算法,这两种算法都可以避免内存碎片。
- 可预测的停顿模型
- G1能让使用者明确指定一个长度为M毫秒的时间段内垃圾回收占用的时间不能超过N毫秒。G1跟踪各个Region里垃圾堆积的价值大小,后台维护一个优先列表,每次根据允许回收的时间优先回收价值最大的Region,保证了G1收集器在有限的时间内可以获得尽可能高的收集效率
相较于CMS,G1还不具备全范围,压倒性的优势。比如在用户程序允许过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(OverLoad)都要比CMS要高。通常来说在小内存上CMS表现大概率会由于G1,而G1在大内存应用上会更有优势。CMS与G1的平衡点大概在6-8GB内存之间。
Region
虽然G1还保留着新生代和老年代的概念,但新生代和老年代不再是物理隔离的,它们都是一部分Region的集合。通过Region的动态分配方式实现逻辑上的连续。
一个空闲的Region可能被分配为Eden,Survivor,Old,Humongous,4中不同的角色,每块区域回收后都会重新分配角色。humongous用户存储大对象,如果一个超过1.5个Region大小,就会被放到humongous角色的region中,如果一个humongous也放不下就会找连续的humongous region进行分配。在Region中,内存的分配使用指针碰撞完成内存分配,TLAB机制在Region中也是存在的
Remembered Set
无论G1 还是其他分代收集器,JVM都采用RememberSet来避免全局扫描(一个年轻代的对象可能被老年代对象引用,那么只扫描年轻代的GCRoots那么不能确定这个对象是否存活)。
- 每个Region对应一个RememberSet
- 每次Reference类型写入的时候都会产生一个Write Barrier暂时中断操作,然后检查将要写入引用指向的对象是否和该Reference类型数据在不同的Region(分代收集器,检查是否老年代对象引用了新生代对象)。如果不同(引用了)通过CardTable把相关引用记录到引用指向对象所在Region的RememberSet中;在GCRoots枚举的范围加入RememberSet记录的引用也作为根扫描。
回收环节
G1的垃圾回收:
G1 中提供了三种垃圾回收模式:YoungGC、MixedGC、和 Full GC,分别在不同条件下触发。(Full GC 单线程,独占式,高强度的Full GC作为失败保护机制任然存在)
年轻代GC
- 在分配一般对象(非巨型对象)时,当所有eden region使用达到最大阀值并且无法申请足够内存时,会触发一次YoungGC。G1年轻代收集阶段是一个并行的独占式的收集器。暂停用户线程,启动多线程进行年轻代手机。每次younggc会回收所有Eden以及Survivor区,并且将存活对象复制到Old区以及另一部分的Survivor区。
混合回收
当内存使用达到一定值(默认45%)时,开始老年代并发标记过程。当标记完成后马上开始混合回收,G1 GC 从老年代区间移动存活对象到空闲空间,这些空闲也将称为老年代的一部分,和年轻代不同,G1的老年代回收并不需要回收整个老年代,一次只扫描回收一部分(满足暂停事件,回收价值高的region),老年代的Region和年轻代Region一起回收。
-XX:+UseG1GC 手动指定使用G1收集器执行内存回收任务
-XX:G1HeapRegionSize 设置每个Region的大小。值必须是2的幂,范围是1MB到32MB之间。使用G1时Java堆会被分为大小统一的的区(region)。此参数可以指定每个heap区的大小. 默认值将根据 heap size 算出最优解
-XX:MaxGCPauseMillis 设置期望达到的最大GC停顿时间指标。G1会尽量满足期望,默认事件是200ms
-XX:ParallelGCThread 设置垃圾收集器在并行阶段使用的线程数。STW是多线程回收垃圾,最多设置为8
-XX:ConcGCThreads 设置并发标记的线程数。
-XX:InitiatingHeapOccupancyPercent 设置触发GC周期的Java堆占用阈值。超过此值触发GC,默认值是45。