JVM基础篇
JVM全称Java Virtual Machine,中文译名Java虚拟机。
职责:
运行Java字节码文件。
- 功能
- 解释和运行
- 对字节码文件中的指令,实时的解释成机器码,让计算机执行
- 内存管理
- 自动为对象、方法等分配内存
- 自动的垃圾回收机制,回收不再使用的对象
- 即时编译(JIT)
- 对热点代码进行优化,提升执行效率
- 简单来说就是将
热点代码存储在内存中
,用空间换时间
- 解释和运行
常见的JVM
JVM的组成
字节码详解
1.字节码文件的组成
- 基础信息
- 魔数、字节码文件对应的Java版本号访问标识(public final等等)
- 父类和接口
- 常量池
- 保存了字符串常量、类或接口名、字段名主要在字节码指令中使用
- 字段
- 当前类或接口声明的字段信息
- 方法
- 当前类或接口声明的方法信息字节码指令
- 属性
- 类的属性,比如源码的文件名内部类的列表等
1.1.基础信息-Magic魔数
我们随意打开俩个字节码文件,可以看到文件开头都是ca fe ba be,那么这个就是class字节码的文件标识
文件是无法通过文件扩展名
来确定文件类型的,文件扩展名可以随意修改,不影响文件的内容。
这里如果使用notepad++ 用16进制打开字节码文件进行篡改则会报错
软件使用文件的头几个字节(文件头)去校验文件的类型,如果软件不支持该种类型就会出错。
文件必须满足以上的要求才可以被打开,那与我们这个魔数有什么关系呢?
其实在Java字节码文件中,将文件头称为magic魔数
,如果我们字节码文件开头不是这个就会报错
1.2.基础信息-主副版本号
主副版本号指的是编译字节码文件的JDK版本号
,主版本号用来标识大版本号,JDK1.0-1.1使用了45.0-45.3,JDK1.2是46之后每升级一个大版本就加1;副版本号是当主版本号相同时作为区分不同版本的标识,一般只需要关心主版本号。
比如 主版本号为61 - 44 = 17 那么我们就使用的JDK17版本
版本号的作用是干什么呢?
版本号的作用主要是判断当前字节码的版本和运行时的JDK是否兼容。
比如以下报错
可以看到报错信息,我们应该使用的是52 - 44 = 8 但是报错信息告诉我们正在 50 - 44 = 6 我们应该运行jdk8的版本才行
那么解决方案是什么呢?
- 升级JDK版本
- 将第三方依赖的版本号降低或者更换依赖,以满足JDK版本的要求(工作推荐第二种)
1.3.基础信息-常量池
字节码文件中常量池的作用:避免相同的内容重复定义,节省空间。
例如下面这种情况
我们在编译过后,在常量池中数据每个都有个编号,编号从1开始。在字段或者字节码指令中通过编号可以快速的找到对应数据
- 字节码指令通过编号引用到常量池的过程称之为
符号引用
1.4.基础信息-方法
介绍方法前,我们来看一个常见的面试题
最终答案为0
我们来看底层操作
首先解释一下
- i const_0 将0放入
操作数栈
中 - i store_1 将操作数栈中数据 放入在
局部变量表
中 - i load_1 从
局部变量表1
的位置取数据放入操作数栈
中 - i inc 1 by 1 从局部变量表1中加上数字1
然后我们指令集可以看到倒数第二三步骤
首先我们在局部变量表中加上了1 然后我们从操作数栈中把0赋给了 局部变量表数组中的 1 ,1就被0给覆盖掉
那么我们看了i++的底层后,我们来看++i的底层
可以看到我们先在局部变量表中加上1后
然后我们加载到操作数栈中
最后又从栈中弹出赋给局部变量表中1。
那么通过这个案例,我们就可以知道字节码指令
类的生命周期
1.加载阶段
-
加载(Loading)阶段第一步是
类加载器
根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息。- 程序员可以使用Java代码拓展的不同渠道
-
类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区中。
-
类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到内存的方法区中。
- 生成一个InstanceKlass对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息。
注意这里方法区是一个虚拟的概念,在HotSpot早期版本用的是永久代,现在新版本中采用原空间
-
同时,Java虚拟机还会在堆中生成一份与方法区中数据类似的java.lang.Class对象。
- 作用是在Java代码中去获取类的信息以及存储静态字段的数据(JDK8及之后)。
2.连接阶段
2.1.验证
验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与
主要包含四个部分,更详细参考《Java虚拟机规范》:
- 文件格式验证
- 比如文件是否以OxCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求。
- 元信息验证
- 例如类必须有父类(super不能为空)。
- 验证程序执行指令的语义
- 比如方法内的指令执行到一半强行跳转到其他方法中去。
- 符号引用验证
- 例如是否访问了其他类中private的方法等。
2.2.准备
准备阶段为静态变量(static)分配内存并设置初始值。
注意:本章涉及到的内存结构只讨论JDK8及之后的版本,8之前的版本后续章节详述。
- 特殊情况(Final)
- 当被特殊字符Final修饰的时候,这里会直接赋值
2.3.解析
解析阶段主要是将常量池中的符号引用替换为直接引用。
- 直接引用不在使用编号,而是使用内存中地址进行访问具体的数据。
2.4.初始化
- 初始化阶段会执行
静态代码块中的代码
,并为静态变量赋值
。 - 初始化阶段会执行字节码文件中
clinit
部分的字节码指令。
以下几种方式会
导致类的加载
- 访问一个类的静态变量或者静态方法,注意变量是
final修饰
的并且等号右边是常量不会触发
初始化。 - 调用Class.forName(String className)。
- new一个该类的对象时。
- 执行Main方法的当前类。
以下几种情况不会
初始化指令
- 无静态代码块且无静态变量赋值语句
- 有静态变量的声明,但是没有赋值语句。
- 静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。
要点:
1.静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化(除非要执行方法)。
2.直接访问父类的静态变量,不会触发子类的初始化。子类的初始化cinit调用之前,会先调用父类的cinit初始化方法。
类加载器
类加载器(ClassLoader)是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术。
类加载器只参与加载过程中的字节码获取并加载到内存这一部分。
应用场景
类加载器的分类
类加载器分为俩类
- Java代码实现
- Java虚拟机底层源码实现
JDK8版本8之前版本中默认的类加载器有如下几种
我们可以启动arthas来看看是否有这几种
首先我们启动一个类
进入arthas选择这个HD类 输入classloader
1.启动类加载器
- 启动类加载器(Bootstrap ClassLoader)是由Hotspot虚拟机提供的、使用C++编写的类加载器。
- 默认加载Java安装目录/jre/lib下的类文件,比如rt.jar,tools.jar,resources.jar等。
通过启动类加载器区加载用户jar包
- 使用参数进行扩展
- 推荐,使用-Xbootclasspath/a.jar包目录/jar包名 进行扩展
2.默认类加载器
- 扩展类加载器和应用程序类加载器都是JDK中提供的、使用Java编写的类加载器。
- 它们的源码都位于sun.misc.Launcher中,是一个静态内部类。继承自URLClassLoader。具备通过目录或者指定jar包将字节码文件加载到内存中。
- 扩展类加载器(Extension Class Loader)是JDK中提供的、使用Java编写的类加载器。
- 默认加载Java安装目录/jre/lib/ext下的类文件。
通过启动类加载器区加载用户jar包
- 使用参数进行扩展
- 推荐,使用-Djava.ext.dirs=jar包目录进行扩展,这种方式会覆盖掉原始目录,可以用;(windows):(macos/linux)追加上原始目录
双亲委派机制
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,
会自底向上查找是否加载过,再由顶向下进行加载。
1.作用
- 保证类加载的安全性
- 通过双亲委派机制避免恶意代码替奂JDK中的核心类库,比如
- java.lang.string,确保核心类车的完整性和安全性。
- 避免重复加载
- 双亲委派机制可以避免同一个类被多次加载。
2.父类加载器小细节
每个Java实现的类加载器中保存了一个成员变量叫“父”(Parent)类加载器,可以理解为它的上级,并不是继承关系。
- 应用程序类加载器的parent父类加载器是扩展类加载器,而
扩展类加载器的parent是空
,但是在代码逻辑上,扩展类加载器依然会把启动类加载器当成父类加载器处理。 - 启动类加载器使用C++编写,没有父类加载器。
3.打破双亲委派机制
- 自定义加载器
- 自定义类加载器并且重写
loadclass方法,就可以将双亲委派机制的代码去除 - Tomcat通过这种方式实现应用之间类隔离,《面试篇》中分享它的做法
- 自定义类加载器并且重写
- 线程上下文加载器
- 利用上下文类加载器加载类,比如
JDBC和JNDI等
- 利用上下文类加载器加载类,比如
- Osgi框架的类加载器(了解)
- 历史上osgi框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载
3.1.自定义类加载使用场景
一个Tomcat程序中是可以运行多个Web应用的,如果这两个应用中出现了相同限定名的类,比如Servlet类,Tomcat要保证这两个类都能加载并且它们应该是不同的类。
如果不打破双亲委派机制,当应用类加载器加载Web应用1中的MyServlet之后,Web应用2中相同限定名的MyServlet类就无法被加载了。
Tomcat使用了自定义类加载器来实现应用之间类的隔离。每一个应用会有一个独立的类加载器加载对应的类。
如何进行打破双亲委派机制的原理呢,我们先来看原理
先来分析ClassLoader的原理,ClassLoader中包含了4个核心方法。双亲委派机制的核心代码就位于loadClass方法中。
那么我们再来看源码
3.2.如何实现自定义类加载器
正确的去实现一个自定义类加载器的方式是重写findClass
方法,这样不会破坏双亲委派机制。
3.3.利用线程上下文类加载器
这里利用一个JDBC案例给大家讲解
JDBC中使用了DriverManager来管理项目中引入的不同数据库的驱动,比如mysql驱动、oracle驱动。
DriverManager类位于rt.jar包中,由启动类加载器加载。
依赖中的mysql驱动对应的类,由应用程序类加载器来加载。
DriverManager属于rt.jar是启动类加载器加载的。而用户jar包中的驱动需要由应用类加载器加载,这就违反了双亲委派机制。
DriverManager怎么知道jar包中要加载的驱动在哪儿?
这里就要提到JDBC底层机制SPI机制
-
spi全称为(Service Provider Interface),是JDK内置的一种服务提供发现机制。
-
spi的工作原理:
- 在ClassPath路径下的META-INF/services文件夹中,以接口的全限定名来命名文件名,对应的文件里面写该接口的实现。
- 使用ServiceLoader加载实现类。
SPI中是如何获取到应用程序类加载器的?
- SPI中使用了线程上下文中保存的类加载器进行类的加载,这个类加载器一般是应用程序类加载器。
总结
这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,打破了双亲委派机制。
- 启动类加载器加载DriverManager。
- 在初始化DriverManager时,通过SPI机制加载jar包中的myql驱动。
- SPI中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象。
JDK9之后的类加载器
我们先来看JDK8
版本以及之前
JDK8及之前的版本中,扩展类加载器和应用程序类加载器的源码位于rt.jar包中的sun.misc.Launcher.java。
JDK9之后引入了module的概念,类加载器在设计上发生了很多变化。
1.启动类加载器使用Java编写,位于jdk.internal.loader.ClassLoaders类中。
Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件。
启动类加载器依然无法通过java代码获取到,返回的仍然是null,保持了统一。
2.扩展类加载器被替换成了平台类加载器(Platform Class Loader)。
平台类加载器遵循模块化方式加载字节码文件,所以继承关系从URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件。
平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑。
运行时数据区域
Java虚拟机在运行Java程序过程中管理的内存区域,称之为运行时数据区。《Java虚拟机规范》中规定了每一部分的作用。
程序技术器
- 程序计数器〈Program Counter Register)也叫
PC寄存器
,每个线程会通过程序计数器记录当前要执行的的字节码指令的地址。
- 在多线程执行情况下,
Java
虚拟机需要通过程序计数器记录CPU
切换前解释执行到那一句指令并继续解释运行。
Java虚拟机栈
-
Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,
先进后出(First In Last Out)
,每一个方法的调用使用一个栈帧(Stack Frame)来保存。 -
那么我们如何来看到这个栈帧呢,其实我们每次报错已经给我们提示出来了例如这样
-
Java虚拟机栈
随着线程的创建而创建,而回收则会在线程的销毁时进行
。由于方法可能会在不同线程中执行,每个线程都会包含一个自己的虚拟机栈。
1.什么是栈帧
栈帧的组成
2.栈帧组成-局部变量表
局部变量表的作用是在方法执行过程中存放所有的局部变量。编译成字节码文件时就可以确定局部变量表的内容。
栈帧中的局部变量表是一个数组,数组中每一个位置称之为槽(slot), long和double类型占用两个槽,其他类型占用一个槽。
- 方法参数也会保存在局部变量表中,其顺序与方法中参数定义的顺序一致。
- 司部变量表保存的内容有:实例方法的this对象,方法的参数,方法体中声明的局部变量。
- 为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用。
3.栈内存溢出
- Java虚拟机栈如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出。
- Java虚拟机栈内存溢出时会出现StackOverflowError的错误
如果我们不指定栈的大小,JVM将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构。
4.设置栈大小
- 要修改Java虚拟机栈的大小,可以使用虚拟机参数-Xss。
- 语法:-Xss栈大小
- 单位:字节(默认,必须是1024的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
注意事项
windows (64位)下的JDK8测试最小值为180k,最大值为1024m。
堆
- 一般Java程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。
- 栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。
堆内存大小是有上限的,当对象一直向堆中放入对象达到上限之后,就会抛出outOfMemory
错误。
堆空间有三个需要关注的值,used total max。
used指的是当前已使用的堆内存
total是java虚拟机已经分配的可用堆内存
max是java虚拟机可以分配的最大堆内存。
如果堆内存不足,java虚拟机就会不断的分配内存,total值会变大。total最多只能与max相等。
1.设置堆大小
如果不设置任何的虚拟机参数,max默认是系统内存的1/4,total默认是系统内存的1/64。在实际应用中一般都需要设置total和max的值。
要修改堆的大小,可以使用虚拟机参数-Xmx (max最大值)和-Xms(初始的total)。
语法:-Xmx值 -xms值
单位:字节(默认,必须是1024的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
限制:Xmx必须大于2 MB,Xms必须大于1MB
Java服务端程序开发时,建议将-Xmx和-Xms设置为相同的值
,这样在程序启动之后可使用的总内存就是最大内存,而无
心
需向java虚拟机再次申请,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况。
方法区
方法区是存放基础信息的位置,线程共享,主要包含三部分内容:
方法区是《Java虚拟机规范》中设计的虚拟概念,每款Java虚拟机在实现上都各不相同。Hotspot设计如下:
JDK7及之前的版本
将方法区存放在堆区域中的永久代空间
,堆的大小由虚拟机参数来控制。JDK8及之后的版本
将方法区存放在元空间
中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。
1.元信息
方法区是用来存储每个类的基本信息(元信息
),一般称之为InstanceKlass对象
。在类的加载阶段完成。
2.运行时常量池
- 方法区除了存储类的元信息之外,还存放了运行时常量池。常量池中存放的是字节码中的
常量池内容
。 - 字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为
运行时常量池
。
3.字符串常量池
- 方法区中除了类的元信息、运行时常量池之外,还有一块区域叫字符串常量池(StringTable)。
- 字符串常量池存储在代码中定义的常量字符串内容。比如“123”这个123就会被放入字符串常量池。
4.溢出问题
- JDK7将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数
-XX:MaxPermSize=值
来控制。 - JDK8将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。可以使用
-XX:MaxMetaspaceSize=值
将元空间最大大小进行限制。
这里建议设置成256M即可,防止部署多个Java程序时占用过大,导致其他Java程序崩溃
直接内存
直接内存(Direct Memory)并不在《Java虚拟机规范》中存在,所以并不属于Java运行时的内存区域。在JDK1.4中引入了NIO机制,使用了直接内存,主要为了解决以下两个问题:
1、Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
2、IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中。
现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。
如果需要手动调整直接内存的大小,可以使用
-XX:MaxDirectMemorySize=大小
单位k或K表示千字节,m或M表示兆字节,g或G表示千兆字节。默认不设置该参数情况下,JVM自动选择最大分配的大小。
以下示例以不同的单位说明如何将直接内存大小设置为1024 KB:-XX:MaxDirectMemorySize=1m
-XX:MaxDirectMemorySize=1024k-XX:MaxDirectMemorySize=1048576
自动垃圾回收
Java中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection简称GC)
机制。通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上的内存进行回收。其他很多现代语言比如C#、Python、Go都拥有自己的垃圾回收器。
方法区的回收
-
方法区中能回收的内容主要就是不再使用的类。判定一个类可以被卸载。需要同时满足下面三个条件:
-
此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。
-
加载该类的类加载器已经被回收。
-
该类对应的java.lang.Class对象没有在任何地方被引用。
-
1.手动触发回收
如果需要手动触发垃圾回收,可以调用System.gc()方法。
-
语法: System.gc()
-
注意事项:
调用System.gc()方法并
不一定会立即回收垃圾
,仅仅是向Java虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收Java虚拟机会自行判断。
堆回收
如何判断堆上的对象可以回收?
Java中的对象是否能被回收,是根据对象是否被引用
来决定的。如果对象被引用了,说明该对象还在使用,不允许被回收。
1.引用计数法
引用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1。
由于无法解决循环依赖问题所以Java使用了下面这个方法
2.可达性分析法
Java使用的是可达性分析算法
来判断对象是否可以被回收。可达性分析将对象分为两类:垃圾回收的根对象(GCRoot)和普通对象,对象与对象之间存在引用关系。
下图中A到B再到C和D,形成了一个引用链,可达性分析算法指的是如果从某个到GC Root对象是可达的,对象就不可被回收。
这里如果我们断开对象A 跟对象B
那么BCD都可以被回收
如何区分GC Root对象呢
- 线程Thread对象,引用线程栈帧中的方法参数、局部变量等。
- 系统类加载器加载的java.lang.Class对象。
- 监视器对象,用来保存同步锁synchronized关键字持有的对象。
- 本地方法调用时使用的全局对象。
3.五种对象引用
可达性算法中描述的对象引用,一般指的是强引用
,即是GCRoot对象对普通对象有引用关系,只要这层关系存在,普通对象就不会被回收。除了强引用之外,Java中还设计了几种其他引用方式:
- 软引用
- 弱引用
- 虚引用
- 终结器引用
3.1.软引用
软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收
。
在JDK 1.2版之后提供了SoftReference类来实现软引用,软引用常用于缓存
中。
执行过程如下:
- 将对象使用软引用包装起来,new SoftReference<对象类型>(对象)。
- 内存不足时,虚拟机尝试进行垃圾回收。
- 如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象。
- 如果依然内存不足,抛出OutOfMemory异常。
软引用中的对象如果在内存不足时回收,SoftReference对象本身也需要被回收。如何知道哪些SoftReference对象需要回收呢?
SoftReference提供了一套队列机制:
1、软引用创建时,通过构造器传入引用队列、
2、在软引用中包含的对象被回收时,该软引用对象会被放入引用队列3、通过代码遍历引用队列,将SoftReference的强引用删除
3.2.弱引用
弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。在JDK1.2版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal
中使用。
弱引用对象本身也可以使用引用队列进行回收。
3.3.虚引用和终结者引用
这两种引用在常规开发中是不会使用的。
虚引用也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java中使用PhantomReference实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。
终结器引用指的是在对象需要被回收时,对象将会被放置在Finalizer类中的引用队列中,并在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做,如果耗时过长会影响其他对象的回收。
垃圾回收算法
核心思想
Java是如何实现垃圾回收的呢?
简单来说,垃圾回收要做的有两件事:
1、找到内存中存活的对象
2、释放不再存活对象的内存,使得程序能再次利用这部分空间
垃圾回收算法的评判标准
Java垃圾回收过程会通过单独的GC线程来完成,但是不管使用哪一种GC算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为Stop The World简称STW
,如果STW时间过长则会影响用户的使用。
所以判断GC算法是否优秀,可以从三个方面来考虑:
1.吞吐量
吞吐量指的是CPU用于执行用户代码的时间与CPU总执行时间的比值,即吞吐量=执行用户代码时间/(执行用户代码时间+GC时间)。吞吐量数值越高,垃圾回收的效率就越高。
2.最大暂停时间
最大暂停时间指的是所有在垃圾回收过程中的STW时间最大值。比如如下的图中,黄色部分的STW就是最大暂停时间,显而易见上面的图比下面的图拥有更少的最大暂停时间。最大暂停时间越短,用户使用系统时受到的影响就越短。
3.堆使用效率
不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法。
上述三种评价标准:堆使用效率、吞吐量,以及最大暂停时间不可兼得。
一般来说,堆内存越大,最大暂停时间就越长。想要减少最大暂停时间,就会降低吞吐。
不同的垃圾回收算法,适用于不同的场景。
常见垃圾回收算法
-
1960年John McCarthy发布了第一个GC算法:标记-清除算法。
-
1963年Marvin L. Minsky 发布了复制算法。
本质上后续所有的垃圾回收算法,都是在上述两种算法的基础上优化而来。
1.标记清除算法
标记清除算法的核心思想分为两个阶段:
- 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
- 清除阶段,从内存中删除没有被标记也就是非存活对象。
优缺点
优点
:
实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。
缺点
:
1.碎片化问题
由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。
2.分配速度慢。
由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。
这里类似与操作系统的内存管理
2.复制算法
复制算法的核心思想是:
1.准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。
2.在垃圾回收GC阶段,将From中存活对象复制到To空间。
3.将两块空间的From和To名字互换。
优缺点
优点
1.吞吐量高
复制算法只需要遍历一次存活对象复制到To空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法,因为标记清除算法不需要进行对象的移动
2.不会发生碎片化
复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
缺点
1.内存使用效率低
每次只能让一半的内存空间来为创建对象使用
3.标记整理算法
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。核心思想分为两个阶段:
1.标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
2.整理阶段,将存活对象移动到堆的一端。清理掉未存活对象的内存空间。
优缺点
优点
1.内存使用效率高
整个堆内存都可以使用,不会像复制算法只能使用半个堆内存
2.不会发生碎片化
在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间
缺点
1.整理阶段的效率不高
整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过Two-Finger、表格算法、工mmixGc等高效的整理算法优化此阶段的性能
4.分代垃圾回收算法
现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)
。
分代垃圾回收将整个内存区域划分为年轻代
和老年代
:
虚拟机参数
分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。
随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC
,称为Minor GC或者Young GC。
Minor GC会把需要eden中和From需要回收的对象回收,把没有回收的对象放入To区。
接下来,SO会变成To区,S1变成From区。当eden区满时再往里放入对象,依然会发生Minor GC.。
此时会回收eden区和S1(from)中的对象,并把eden和from区中剩余的对象放入SO。注意:每次Minor GC中都会为对象记录他的年龄,初始值为0,每次GC完加1。
如果Minor GC后对象的年龄达到阈值(最大15
,默认值和垃圾回收器有关),对象就会被晋升至老年代。
当老年代中空间不足,无法放入新的对象时,先尝试minor gc
如果还是不足
,就会触发 Full GC
,Full GC会对整个堆进行垃圾回收。
如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。
注意:这里如果是to区已经满了,晋升年龄还未到达我们设置的阈值,to区的对象也会被防区老年代中
分代GC算法将堆分成年轻代和老年代主要原因有:
1、可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
2、新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。
3、分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(fullgc),STw时间就会减少。
垃圾回收器组合使用
垃圾回收器是垃圾回收算法的具体实现。
由于垃圾回收器分为年轻代和老年代,除了G1之外其他垃圾回收器必须成组合进行使用。具体的关系图如下:
红实线代表可以组合使用
1.1.年轻代-Serial垃圾回收器
Serial是是一种单线程串行
回收年轻代的垃圾回收器。
回收年代和算法
- 年轻代
- 复制算法
优点
- 单CPU处理器下吞吐量非常出色
缺点
- 多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
适用场景
- Java编写的客户端程序或者硬件配置有限的场景
1.2.老年代-SerialOld垃圾回收器
SerialOld是Serial垃圾回收器的老年代版本,采用单线程串行回收
-XX :+UseSerialGC新生代、老年代都使用串行回收器。
回收年代和算法
- 老年代
- 标记-整理算法
优点
- 单CPU处理器下吞吐量非常出色
缺点
- 多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
适用场景
- Serial垃圾回收器搭配使用,或者在CMS特殊情况下使用
2.1.年轻代-ParNew垃圾回收器
ParNew垃圾回收器本质上是对Serial在多CPU下的优化,使用多线程
进行垃圾回收
-XX :+UseParNewGC新生代使用ParNew回收器
,老年代使用串行回收器
回收年代和算法
- 年轻代
- 复制算法
优点
- 多CPU处理器下停顿时间较短
缺点
- 吞吐量和停顿时间不如G1,所以在JDK9之后不建议使用
适用场景
- JDK8及之前的版本中,与CMS老年代垃圾回收器搭配使用
2.2.老年代-CMS垃圾回收器
CMS(并发标记清理算法 Concurrent Mark Sweep)垃圾回收器关注的是系统的暂停时间
,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间。
参数:XX:+UseConcMarkSweepGC
1.初始标记,用极短的时间标记出GC Roots能直接关联到的对象。
2.并发标记,标记所有的对象,用户线程不需要暂停。
3.重新标记,由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记。
4.并发清理,清理死亡的对象,用户线程不需要暂停。
回收年代和算法
- 老年代
- 标记-清除算法
优点
- 系统由于垃圾回收出现的停顿时间较短,用户体验好
缺点
- 内存碎片问题
- CMS使用了标记-清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS会在Full GC时进行碎片的整理。这样会导致用户线程暂停
- 可以使用-XX:CMSFullGcsBeforeCompaction=N参数(默认O)调整N次Full GC之后再整理。
- 退化问题
- 如果老年代内存不足无法分配对象,CMS就会退化成Serial Old单线程回收老年代。
- 浮动垃圾问题
- 法处理在并发清理过程中产生的“浮动垃圾”,不能做到完全的垃圾回收。
适用场景
- 大型的互联网系统中用户请求数据量大、频率高的场景
比如订单接口、商品接口等
3.1.年轻代-Parallel Scavenge垃圾回收器
Parallel Scavenge是JDK8默认的年轻代垃圾回收器,多线程并行回收,关注的是系统的吞吐量。具备自动调整堆内存大小的特点
。
回收年代和算法
- 年轻代
- 复制算法
优点
- 吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数
缺点
- 不能保证单次的停顿时间
适用场景
- 后台任务,不需要与用户交互,并且容易产生大量的对象
比如:大数据的处理,大文件导出
3.2.老年代-Parallel Old垃圾回收器
Parallel Old是为Parallel Scavenge收集器设计的老年代版本,利用多线程并发 收集。
参数:
-XX:+UseParallelGC或
-XX:+UseParallelOldGC可以使用
Parallel Scavenge + Parallel Old这种组合。
回收年代和算法
- 老年代
- 标记-整理算法
优点
- 并发收集,在多核CPU下效率较高
缺点
- 暂停时间会比较长
适用场景
- 与Parallel Scavenge配套使用
Parallel Scavenge允许手动设置最大暂停时间和吞吐量。
Oracle官方建议在使用这个组合时,不要设置堆内存的最大值
,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小。
最大暂停时间
- -XX:MaxGCPauseMillis=n
- 设置每次垃圾回收时的最大停顿毫秒数
吞吐量
- -XX:GCTimeRatio=n设置吞吐量为n(用户线程执行时间= n/n + 1)
自动调整内存大小
- -XX:+UseAdaptiveSizePolicy设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小
4.G1垃圾回收器
JDK9之后强烈建议使用G1垃圾回收器。
JDK9之后默认的垃圾回收器是G1(Garbage First)垃圾回收器。
Parallel Scavenge关注吞吐量,允许用户设置最大暂停时间,但是会减少年轻代可用空间的大小。CMS关注暂停时间,但是吞吐量方面会下降。
而G1设计目标就是将上述两种垃圾回收器的优点融合:
1.支持巨大的堆空间回收,并有较高的吞吐量。
2.支持多CPU并行垃圾回收。
3.允许用户设置最大暂停时间。
4.1.内存结构
G1出现之前的垃圾回收器,内存结构一般是连续的,如下图:
G1的整个堆会被划分成多个大小相等的区域,称之为区Region,区域不要求是连续的。分为Eden、Survivor、Old区。Region的大小通过堆空间大小/2048计算得到,也可以通过参数-XX:G1HeapRegionSize=32m指定(其中32m指定region大小为32M),Region size必须是2的指数幂,取值范围从1M到32M
4.2.回收方式
G1垃圾回收有两种方式:
-
年轻代回收(Young GC)
- 年轻代回收 (Young GC),回收Eden区和Survivor区中不用的对象。会导致STW,G1中可以通过参数-XX:MaxGCPauseMillis=n(默认200)设置每次垃圾回收时的最大暂停时间毫秒数G1垃圾回收器会尽可能地保证暂停时间。
- 新创建的对象会存放在Eden区。当G1判断年轻代区不足(
max默认60%
),无法分配对象时需要回收时会执行 young GC - 标记出Eden和Survivor区域中的存活对象
- 根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中(年龄+1),清空这些区域。
- 后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区。
- 当某个存活对象的年龄到达阈值(默认15),将被放入老年代。
- 部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为
Humongous区
。比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M就被放入Humongous区,如果对象过大会横跨多个Region。
公
- 多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值时(-XX:InitiatingHeap0ccupancyPercent
默认45%
)会触发混合回收MixedGC
。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来成。
-
混合回收(Mixed GC)
- 混合回收分为:初始标记(initial mark)、并发标记(concurrent mark)、最终标记(remark或者Finalize
Marking)、并发清理(gleanup) - G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbagefirst)名称的由来。
- G1对
老年代的清理会选择存活度最低的区域
来进行回收,这样可以保证回收效率最高,这也是 G1(Garbagefirst)名称的由来。
最后清理阶段使用复制算法,不会产生内存碎片。
- 注意:如果清理过程中发现没有足够的空Region存放转移的对象,会出现Full GC。单线程执行标记-整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。
- 混合回收分为:初始标记(initial mark)、并发标记(concurrent mark)、最终标记(remark或者Finalize
4.3.参数及其优缺点
参数1: -XX:+UseG1GC打开G1的开关,JDK9之后默认不需要打开
参数2:-XX:MaxGCPauseMillis=毫秒值最大暂停的时间
回收年代和算法
- 年轻代+老年代
- 复制算法
优点
- 对比较大的堆如超过6G的堆回收时,延迟可控
- 不会产生内存碎片
- 并发标记的SATB算法效率高
缺点
- JDK8之前还不够成熟
适用场景
- JDK8最新版本、JDK9之后建议默认使用
新的Survivor区中(年龄+1),清空这些区域。
[外链图片转存中…(img-HaHQ4hrt-1720511466023)]
- 后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区。
- 当某个存活对象的年龄到达阈值(默认15),将被放入老年代。
- 部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为
Humongous区
。比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M就被放入Humongous区,如果对象过大会横跨多个Region。
公
[外链图片转存中…(img-6SK7iCh1-1720511466024)]
-
多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值时(-XX:InitiatingHeap0ccupancyPercent
默认45%
)会触发混合回收MixedGC
。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来成。 -
混合回收(Mixed GC)
- 混合回收分为:初始标记(initial mark)、并发标记(concurrent mark)、最终标记(remark或者Finalize
Marking)、并发清理(gleanup) - G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbagefirst)名称的由来。
[外链图片转存中…(img-ZuBtdrfD-1720511466024)]
- G1对
老年代的清理会选择存活度最低的区域
来进行回收,这样可以保证回收效率最高,这也是 G1(Garbagefirst)名称的由来。
最后清理阶段使用复制算法,不会产生内存碎片。
[外链图片转存中…(img-eGXi8rYL-1720511466025)]
- 注意:如果清理过程中发现没有足够的空Region存放转移的对象,会出现Full GC。单线程执行标记-整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。
[外链图片转存中…(img-48iK6IR9-1720511466025)]
- 混合回收分为:初始标记(initial mark)、并发标记(concurrent mark)、最终标记(remark或者Finalize
4.3.参数及其优缺点
参数1: -XX:+UseG1GC打开G1的开关,JDK9之后默认不需要打开
参数2:-XX:MaxGCPauseMillis=毫秒值最大暂停的时间
[外链图片转存中…(img-L7sSiKJt-1720511466025)]
回收年代和算法
- 年轻代+老年代
- 复制算法
优点
- 对比较大的堆如超过6G的堆回收时,延迟可控
- 不会产生内存碎片
- 并发标记的SATB算法效率高
缺点
- JDK8之前还不够成熟
适用场景
- JDK8最新版本、JDK9之后建议默认使用