一、JVM基本结构
二、JVM中内存结构
1、方法区
- 是一个JVM的规范,所有Java虚拟机必须遵守的。是一个逻辑概念;
- 是线程共享的,用于存储类信息,如:类字段、方法数据、常量池等;
- 方法区的大小决定了JVM可以存储和运行多少类;
- 在Java7之前,称为永久代;
- Java8之后(包含)称为元空间
为什么使用元空间替代永久代呢?
- 永久代是从系统内存中申请到的一片固定大小的内存空间,若程序包含的类较多,很容易撑爆永久代空间,只能通过指定JVM参数的方式改变永久代空间;Java8之后采用元空间代替永久代,是因为元空间在不指定其大小的前提下以系统内存作为瓶颈,不用担心OOM;
- Oracle吸取了JRocket没有永久代的依然保持性能相近的经验,在Java8使用元空间代替之;
2、Java堆
- 用于存放所有对象的空间,线程共享
- 运行时数据区,几乎所有的对象都保存在其中
- 通过垃圾回收器管理,堆是垃圾收集器进行GC最重要的区域;
- Java堆可以分为:新生代(Eden区、S0区、S1区)和老年代;
- 在绝大多数情况下,对象首先被分配在Eden区,在一次YoungGC中,90%的对象都会被清理掉,幸存的对象将会被转移至S0或S1,每经历一次YoungGC,未被清理的对象年龄就会+1,当幸存对象的年龄达到阈值(默认15)或满足其他某些条件后(如大对象),就会被认定为是老年代对象,从而进入老年代;
3、Java栈和本地方法栈
- 栈都是线程私有的;
- 虚拟机栈是服务于Java方法,本地方法栈是服务于native方法的;
- 每次函数调用的数据都是通过栈进行传递的;
- 在栈中的保存的主要内容为栈帧。当函数被调用时函数入栈,函数执行完成时出栈。栈顶则是当前正在执行的函数;
- 每个方法在执行的同时都会创建一个栈帧用于存放局部变量表、操作数栈、帧数据区,等信息;
如下图为栈帧操作
4、程序计数器
- 当前线程所执行的字节码行号指示器,指向虚拟机字节码指令的位置;
- 空间极小;
- Native方法与非Native方法的区别:
针对非Native方法:当前线程的字节码行号指示器;
针对于Native方法:则为undefined; - 每个线程都会有自己独立的程序计数器,所以该区域是线程私有的;
- 这一块区域是JVM中唯一没有OOM的区域;
5、执行引擎
负责执行虚拟加载进来的字节码
二、类加载子系统
负责将本地Class文件加载进入JVM。
1、类加载器
1.1 类加载器概述
类加载器负责将Java程序运行过程中将Class文件动态加载至JVM中,
Java中类加载器有三种:启动类加载器(BootstrapClassLoader)、扩展类加载器(ExtensionClassLoader)、应用/系统类加载器(ApplicationClassLoader)
- 启动类加载器(rt.jar包下的类):负责加载java.lang.ClassLoader,是所有ClassLoader的父类。启动类加载器并非是由Java程序编写的,所以在Java运行程序中是为null的。
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoaderTest clt = new ClassLoaderTest();
Class clazz = clt.getClass();
System.out.println(clazz.getClassLoader()); //应用/系统类加载器
System.out.println(clazz.getClassLoader().getParent()); //扩展类加载器
System.out.println(clazz.getClassLoader().getParent().getParent()); //启动类加载器,由于不是由Java程序编写的,所以为null
}
}
//结果为
//sun.misc.Launcher$AppClassLoader@dad5dc
//sun.misc.Launcher$ExtClassLoader@7aac27
//null
- 扩展类加载器(ext/*.jar包下的类):负责加载Java核心类的扩展类;
- 应用/系统类加载器:负责将应用程序级别(后端程序员编写的Java’代码)的类加载到JVM中;
类加载器的运行过程:
1.2 双亲委派机制
上图说明了类加载流程的同时也说明了双亲委派机制:加载类时,每一个类加载器都会将当前类传给上一级类加载器。当启动类加载器无法加载该类时,将类向下传递,由下级类加载器加载,当所有类加载器都无法找到当前类时,抛出异常
2、类加载流程
涉及Java类的加载过程:加载、验证、准备、解析、初始化
- 加载:
- 通过全限定名称获取该Class文件的二进制流;
- 将静态的存储结构转化为方法区中运行时数据结构;
- 在内存中生成代表这个类的Class对象;
- 验证:
- 文件格式验证:验证字节流是否符合Class文件格式的规范, 验证内容主要有:开头魔数、主次版本号、常量标志等
- 源数据验证:验证字节码语义,包括继承关系、抽象语法等
- 字节码验证:验证程序语义是否合法、符合正常逻辑;
- 符号引用验证:该类是否缺少或被禁止访问其依赖的某些外部类、方法、字段等资源;
- 准备:
- 为静态变量分配内存并赋初始值;
- 解析:
- 将符号引用替换为直接引用;
- 初始化:
- 为静态变量赋予代码中指定的值;
- 为成员变量赋予调用类构造方法时指定的值。
三、JVM垃圾回收算法
1、四种引用级别
- 强引用
// object对象就是强引用
Object object = new Object();
- 软引用
在切断对该对象的引用时,只有当堆内存不足时才会被回收
Student stu = new Student();
stu.setName("张三");
WeakReference<Student> weakReference = new WeakReference<>(stu);
stu = null;
if (weakReference.get() != null){
System.out.println(weakReference.get());
}else {
System.out.println("对象为空");
}
//结果为:Student{name='张三'}
- 软引用
在将对象置为空后,会立即被垃圾回收器回收
Student stu = new Student();
stu.setName("张三");
PhantomReference<Student> phantomReference = new PhantomReference<>(stu, new ReferenceQueue<Student>());
stu = null;
if (phantomReference.get() != null){
System.out.println(phantomReference.get());
}else {
System.out.println("对象为空");
}
//结果为:对象为空
- 虚引用
任何情况下都获取不到该对象
2、JVM中的几个重要概念
- 可触及性,可触及性分为三种状态:
- 可触及:从根节点开始,可以到达某个对象;
- 对象引用被释放,但是在被销毁的过程中在finalize()函数中被重新初始化复活;
- 由于finalize()只能被执行一次,所以错过这一次机会的对象,则为不可触及状态
public class FinalizeObject {
private static FinalizeObject finalizeObject;
public static void main(String[] args) {
finalizeObject = new FinalizeObject();
for (int i = 0; i <= 1; i++) {
System.out.println(String.format("--------------GC nums=%d--------------", i));
finalizeObject = null; //将finalizeObject对象置为“垃圾对象”
System.gc();//通知JVM运行GC
try {
Thread.sleep(100);//等待gc执行
} catch (InterruptedException e) {
e.printStackTrace();
}
if (finalizeObject == null){
System.out.println("finalizeObject is dead");
}else {
System.out.println("finalizeObject is still alive");
}
}
}
//finalize只会被调用一次,给正在被销毁的对象唯一一次复活的机会
@Override
protected void finalize() throws Throwable {
System.out.println("对象正在被销毁");
super.finalize();
finalizeObject = this;
}
}
//--------------GC nums=0--------------
//对象正在被销毁
//finalizeObject is still alive
//--------------GC nums=1--------------
//finalizeObject is dead
- 槽位复用,默认开启
/**
* 槽位复用
* 对于变量a而言,其作用域仅在代码块之内
* 离开代码块后,即可判定其不再有用
* 这种时候,若在代码块外存在另一个变量b,
* 则变量b会复用变量a的槽位
*/
public void gc1() {
{
int[] a = {1,2,3};
System.out.println(a);
}
int b = 10;
System.out.println(b);
}
- 对象分配
对象分配的过程中,除了可以在堆上分配,也可以在栈上分配或TLAB分配。分配的流程如下:-
先尝试栈上分配,若不满足条件,尝试TLAB分配,若仍不满足,则在堆上分配。
-
栈上分配
- 开启栈上分配JVM参数:
- 对于小&多的对象,可以试图采用栈上分配。此种场景之下,开启栈上分配能够避免大量的GC,由于GC是存在停顿的,若没有栈上分配会产生大量的时间浪费;
- 栈是线程私有的,所以满足栈上分配的对象也应该是线程私有的 ——逃逸分析
- 若一个对象满足栈上分配的话,需要将其打散后放到栈中——标量替换
-
TLAB
- Thread Local Allocation Buffer :线程本地分配缓冲区;
- 将对象放置在堆上,线程共享;
- TLAB空间很小,只占用Eden 1% 的空间
-
堆上分配
进行堆上分配时,对于不满足进入老年代的对象,将其放入Eden区
-
- 逃逸分析
- 开启逃逸分析JVM参数:
由于是线程私有的,则若是逃出改私有线程,则说明逃逸了
- 开启逃逸分析JVM参数:
//未逃逸
public void hello(){
User user = new User();
user.setName("Alvis");
... ...
}
//逃逸
User user;
public void hello(){
user = new User();
user.setName("Alvis");
... ...
}
-
标量替换
- 开启标量替换JVM参数:
- 标量:Java中的基本类型;
- 聚合量:与标量含义相反,可进一步拆解;
- 标量替换就是将聚合量拆解为标量存储于方法栈中。
-
桥接方法
桥接方法是在JVM加载期间对泛型进行类型擦除后,为指定了泛型类型参数的方法生成了一个中转方法;
如下代码,在Dog继承了Animal并指定泛型为String,实现其中eat(String s)方法,当JVM加载Dog和Animal时,会将Animal中的泛型进行类型擦除变成Object,那么Animal中的eat方法的参数将会变成Object类型,则JVM会在Dog类中使用桥接方法将该方法实现。
import java.lang.reflect.Method;
public interface Animal<T> {
public void eat(T s);
//类型擦除后的方法
//public void eat(Object s);
}
class Dog implements Animal<String>{
@Override
public void eat(String s) {
System.out.println("dog eat " + s);
}
//类型擦除后实现的方法(桥接方法)
//@Override
//public void eat(Object s) {
// eat((String) s);
//}
//测试用的main方法
public static void main(String[] args) {
Dog dog = new Dog();
Method[] declaredMethods = dog.getClass().getDeclaredMethods();
for (Method declaredMethod : declaredMethods) {
if (declaredMethod.getName().equals("eat")){
System.out.print(declaredMethod.getName() + " ");
for (Class<?> parameterType : declaredMethod.getParameterTypes()) {
System.out.print(parameterType.getName());
}
System.out.println("\n=================================");
}
}
}
}
//输出结果
//eat java.lang.String
//=================================
//eat java.lang.Object
//=================================
3、主要的垃圾回收算法
- 引用计数法(Reference Counting)
- 对于一个对象A,只要存在任意一个对象引用了A,则A的引用计数器就会加1,当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,则对象A就不可能再被使用。
- 但引用计数器有两个严重问题:
-
无法处理循环引用的情况
-
引用计数器要求在每次因引用产生和消除的时候,需要伴随一个加减法操作,对系统性能有一定影响。
-
- 因此,JVM的并未选择此算法作为垃圾回收算法
- 标记清除法(Mark-Sweep)
- 标记清楚算法是现代垃圾回收算法的思想基础;
- 分为两个极端:标记阶段和清楚阶段;
- 标记清除算法产生最大的问题就是清除之后会产生大量的空间碎片
- 复制算法
- 将原有空间划分为两块。每次只使用其中一块内存,如:A内存GC时将存货的对象复制到B内存中,然后清楚A中的所有对象,并开始使用B内存。
- 复制算法没有内存碎片,并且若垃圾对象很多,这种算法效率很高。唯一的缺点就是只能使用1/2的内存。
- 由于在老年代中存活的对象较多,若采用复制算法每次需要复制的对象很多,会导致效率低下。相反,在新生代中,由于90%以上的对象都是“即将被回收”的,所以复制对象会相对较少,所以复制算法通常被运用于新生代中,
- 标记压缩法
- 标记压缩算法是一种老年代的回收算法,是对标记清除法的一种改进。
- 它首先标记存活的对象,然后将所有存活的对象都压缩到内存的一端,然后再清理所有存活对象之外的空间。
- 该算法不会产生内存碎片,也不用将内存一分为二。
- 分区算法
- 将堆空间划分为若干个连续的小区间,每个区间独立使用、独立回收。由于堆空间较大,针对整个堆空间的GC会比较耗时,通过控制回收小区间的个数,可以大幅度减少GC所带来的停顿。
- 分代算法
- 将堆空间划分为新生代和老年代,根据其不同特点,执行不同的回收算法,提升回收效率;
- 将堆空间划分为新生代和老年代,根据其不同特点,执行不同的回收算法,提升回收效率;
五、JVM中主要的垃圾收集器
1、串行垃圾回收器(SerialGC)
- 串行垃圾回收器的特点:
- 使用单线程进行GC
- 独占式的GC
- 串行垃圾回收器是JVM Client模式下默认的垃圾回收器
2、并行垃圾回收器(ParNew&ParallelGC&ParallelOldGC) - 将串行回收器多线程化。
- 与串行回收器有相同的回收策略、算法、参数
3、并行垃圾回收器——CMS垃圾回收器 - CMS垃圾回收器是针对老年代的垃圾回收器
- CMS垃圾回收器回收步骤
- CMS垃圾回收器回收详解
- 初始标记,初始标记是为了标记GC Roots能够直接关联到的对象,为了防止在标记过程中出现新的根节点,所以需要“STW”;
- 并发标记,并发标记是为了标记根节点之后的对象节点,可以与应用程序并发执行;
- 预清理,计算YoungGC的执行时间,在不进行YoungGC的时候进行预清理
- 重新标记,重新标记是为了补充标记在并发标记过程中,程序运行产生的新对象节点,所以为了防止还有新节点的产生,一样需要“STW”;
- 并发清理,并行清理之前步骤标记到的节点对象;
- 并发重置,重置CMS,为下一次G C做准备。
4、G1垃圾回收器
- G1全称为Garbage First Collector
- G1垃圾回收器是针对整个JVM堆的垃圾回收器,G1回收器将JVM堆划分为多个区域,每个区域都将根据需求成为Eden、Survivor、Old、Humongous,每次收集部分区域来减少GC产生的停顿时间;
- 对象被创建时,若对象不大于指定阈值(-XX:G1HeapRegionSize)则存放于Eden,若大于指定阈值则存放于Humongous区,进行YoungGC后依旧存活的进入Survivor区,Survivor进行GC后进入Old区;
- G1垃圾回收器回收分为四个阶段,其中第四阶段不是必须的,所以不做重点;三个阶段循环往复,不断运行:
- 第一阶段,新生代GC(YoungGC)
- 新生代GC将会将Eden中依然存活的对象和Survivor中依然存活但还不足以进入Old区的对象移动至新的区域,并将该区域作为新的Survivor区;
- 将Survivor中已经满足进入Old区的对象移动至新的区域,并将该区域作为新的Old区;
- 清除原来的Eden和Survivor;
- 第二阶段,并发标记周期
并发标记周期有六个步骤,如下图所示- 初始标记,标记根节点,为确保标记过程中不受到程序运行的影响,此处会“STW”;
- 根区域扫描,扫描Survivor区中即将进入Old区的对象并进行标记,此时在该区域是不会进行YoungGC的;
- 并发标记,扫描根节点之后的各个对象,与应用程序并发执行;
- 重新标记,标记因程序运行产生的新节点,需要进行“STW”;
- 独占清理,会计算每一个区域的垃圾比例并按照垃圾比例进行排序;
- 并发清理,会将每一个垃圾占比为100%的区域清理掉;
- 第三阶段,混合收集
- 负责将独占清理过程中计算出的垃圾比例较高的区域中的垃圾对象。
- 第四阶段,FullGC
- 第一阶段,新生代GC(YoungGC)
七、参考文章
深入理解Java虚拟机[第三版]
类加载器