目录
0 引言
针对JVM常见问题的总结,主要就是分为JVM内存模型、类加载系统、垃圾回收几个部分,主要参考周志明老师的《深入理解Java虚拟机》一书
1 JVM内存结构
JVM内存结构指的是JVM运行时数据区的内存分布,共分为五个部分,分别是虚拟机栈、本地方法栈、程序计数器、堆和方法区,其中虚拟机栈,本地方法栈和程序计数器是线程私有的,堆和方法区是线程共享的。对这些分区的接受如下:
堆
堆区是用来存放Java对象的地方,我们的Java对象基本都是存放在堆中,同时堆区也是JVM垃圾回收最频繁的地方,可能出现OOM
方法区
方法区主要存放了类型数据,存在垃圾回收,但是概率十分小,因为Java中要回收一个类的条件是十分苛刻的,需要注意的是在JDK7以后方法区挪到本地的直接内存中(这样可以看到垃圾回收的概率更小了),在JDK8中方法区也正式更名为元空间
程序计数器
程序计数器是线程私有的,记录了当前正在执行的字节码指令的地址。注意对于本地方法执行时,程序计数器的值为null
本地方法栈
执行本地方法,也就是所谓的native方法的时候是在本地方法栈中进行
虚拟机栈
虚拟机栈是线程私有的,描述的是Java方法执行的线程内存模型,每个方法被执行的时候,JVM都会创建一个栈帧,每一个方法被调用和返回的过程就对应这个一个栈帧的入栈和出栈的过程,栈帧中还有五个基本结构,如下:
-
局部变量表
是一个数组,编译期就确定了大小,数组的每一个存储空间我们称之为1个槽,一个槽能存储32位的数据,我们的对象引用和除long,double外的基本数据类型都只占一个槽,long和double数据占用两个槽,需要强调的一点是,对于非静态方法,其局部变量表中有一个this指针,静态方法中是没有this指针了,这也解释了为什么静态方法中只能使用静态属性
-
操作数栈
我们数据的计算都是在操作数栈中进行的,操作数栈主要用户存储计算的中间结果和临时存放操作数
-
方法返回地址
方法执行完后需要返回该方法被调用处,方法返回地址保存了原方法中该方法执行完后下一条指令的地址
-
动态链接
动态链接是一个指向方法区的引用,指向了该方法的元数据信息,即通过动态链接可以知道该方法是哪个类的方法
-
附加信息
除以上信息外的其他信息
2 Java对象的创建过程
java对象的创建过程主要分为6个步骤,具体过程如下
-
首先查看要创建的对象所对应的类是否已经被加载了,如果没有则需要先加载该对象对应的类(加载的过程也是先加载父类再加载子类)
-
在堆区中给对象实例分配内存空间,这里有两种内存空间的分配方法:
- 内存规整的情况下采用指针碰撞法,就是直接移动指针
- 内存不规整的情况下采用空闲列表法,内存不规整也就是说存在内存碎片,将空闲的内存维护在一个空闲列表中,然后根据对象大小在空闲列表中选择合适的空闲内存进行内存分配
-
处理一些并发安全问题
这是给对象分配内存空间的过程中需要考虑的问题,因为对象的创建非常频繁,若在对象A创建的过程中(指针还未来得及修改)又创建对象B就会产生并发安全的问题,为解决这个问题,虚拟机采用的CAS+失败重试;同时我们还可以给线程分配独立的TLAB,这样线程要创建对象时优先在TLAB中进行内存分配,TLAB不够的时候再在共享的堆内存中进行分配
-
字段初始化,这时候需要对实例数据进行默认初始化,默认初始化的目的是为了使java程序中的字段在未赋值的情况下能够直接使用
-
设置对象头
-
调用方法进行对象字段初始化,代码块初始化以及构造器初始化
3 Java中对象的内存布局
对象的内存布局如下:
-
对象头
-
markword
存储对象的运行时状态数据,比如哈希码,GC分代年龄,锁状态标志,偏向线程ID等信息
-
klass
指向对象元数据类型的指针,通过这个指针我们可以直到这个对象是哪个类的实例
-
数组长度
这是数组类型对象才有的一个属性,对于非数组对象可以通过对象的类元数据来获取对象的大小
-
-
实例数据
存储对象字段属性的地方,包括对象本身的属性和其父类的属性,一般来说父类数据在子类数据之前
-
对其填充
这部分不一定有,仅仅起到了占位符的作用,Hotspot虚拟机规定了对象的大小必须是8字节的整数倍,当对象头和实例数据之和不满足8字节的整数倍的时候就会通过对齐填充来补上缺少的字节数
4 类加载过程
java程序是运行在java虚拟机上的,首先需要将.java文件编译成.class文件,然后再由类加载器子系统将其装载到java虚拟机中后运行,java类加载的过程如下:
-
加载
该阶段主要做了以下三件事
- 根据类的全限定名获得该类的二进制字节流
- 将class文件中的静态存储结构转换为运行时数据
- 在堆中创建该类的Class对象,作为该类的类元数据的访问入口
-
链接
该阶段又分为三个过程:
-
验证
主要进行字节码文件的安全性校验,比如格式校验,语法予以校验等
-
准备
该阶段主要进行静态变量的默认初始化,static修饰的静态字段在该阶段将赋给默认值,需要注意的是对于final修饰的字段,在编译期就已经完成默认初始化,该阶段将完成赋值行为
-
解析
对于那些在编译期就能够确定调用的是哪个类的方法的方法(非虚方法)在该阶段就会将符号引用转换为直接引用
-
-
初始化
该阶段会将类中的静态字段和静态代码块按序收集起来,然后放入到一个方法中执行,即进行静态变量的显式初始化工作
5 双亲委派模型
什么是双亲委派模型
双亲委派模型体现在ClassLoader的loadClass()方法中,主要思想就是当类加载器接受到一个类加载请求的时候并不会直接去加载这个类,而是会将该类委托给该类的父类加载器来加载(注意这里说的父类加载器并不是指的继承关系,在ClassLoader中有一个parent属性,指向一个类加载器,我们称之为该类的父类加载器)。Java中共有四种类加载器:
- 启动类加载器 加载核心类库中的类
- 扩展类加载器 加载扩展包中的类
- 应用类加载器 加载自定义的类
- 自定义类加载 加载指定的类
在双亲委派模型中,当我们加载一个普通的类时,首先是应用类加载器接受加载请求,然后会向上传递给扩展类加载器,然后扩展类加载器再传递给启动类加载器,启动类加载器查看该类自己能否加载,能则直接返回,不能则再交由扩展类加载器加载,扩展类加载器查看该类自己能否加载,能则直接返回,不能则再交由应用类加载器加载,应用类加载器拿到加载请求后加载该类即完成类加载。
双亲委派模型的作用
- 避免类的重复加载,双亲委派模型中的核心代码是在同步代码块中的,而且在代码块中最开始会检验该类是否已经加载过了,所以保证了类只会被加载一次
- 保护Java的核心类库,举个例子,当我们自己创建一个java.lang包然后在包中创建一个String类后再去new这个类的实例的时候我们并不能加载这个类,因为类加载请求会最先到启动类加载器,启动类加载器根据类的全限定名会直接加载核心类库中的String后返回
6 如何自定义一个类加载器
类加载是遵守双亲委派模型的,具体实现是在loadClass方法中,若经过双亲委派模型还没有得到类的Class对象时,就会调用当前类加载器的findClass()方法,所以我们要自定义类加载器就是要重写findClass()方法,一般不是去重写loadClass()方法,因为这样做会破坏双亲委派模型。
- 写一个类继承自ClassLoader类
- 重写类的findClass()方法,该方法要做的事情就是,获取该类字节码文件的字节数组,然后将该字节数组转换为Class对象,要想字节数组转换为Class对象只需要调用defineClass方法即可。
7 类加载过程中的两个异常
-
ClassNotFoundException
编译时在提供的类路径下找不到相应的类就会抛异常
-
NoClassDefFoundError
编译时在提供的类路径下能找到响应的类,但是运行过程中,需要加载这个类的时候找不到对应的类文件
8 如何判断对象已死?
垃圾回收目的就是清理掉哪些已经不会再被使用的对象所占用的内存空间,那么要如何判断对象已死呢?一般来说有两种算法,分别是引用计数算法和可达性分析算法,具体介绍如下
引用计数算法
引用计数算法的实现比较简单,就是在对象中维护一个计数器,每当有一个引用指向这个对象的时候,引用计数器的值就加1,当引用计数器的值为0是则表示该对象是一个垃圾对象可以被回收了,这个算法有一个明显的缺点就是对于循环应用的对象,即使这些对象已经不被使用了,但是仍然存在内存中不被回收,造成内存泄漏
可达性分析算法
从一个被称为GC Roots对象出发,开始往下搜索,如果一个对象到GC Roots没有任何的关联,那么我们就认为这个对象是垃圾对象。在java中有以下对象可以作为GC Roots:
- 虚拟机栈中引用的对象
- 方法区类变量引用的对象(类变量和字符串常量池在JDK7时转移到了堆中)
- 方法区常量池引用的对象
- 本地方法栈JNI引用的对象
- 在对年轻代进行收集器,堆中对年轻代有引用的对象
当满足上述条件时,并不会对这个对象立刻执行回收,还会进行两次判断:
- 判断该对象是否重写了finalize方法并且没有执行过,若不满足则标记为垃圾对象,满足则进行下一次判断
- 将该对象放入一个F-Queue队列之中并生成一个finalize线程去执行器finalize方法(虚拟机并不保证该方法一定会被执行),若finalize()方法执行后该对象仍然没有与GC Roots中的对象产生关联,则该对象被标记为垃圾对象
9 垃圾收集算法
-
标记-清除算法
-
执行流程:
-
标记
从GC Roots出发,对堆内存中的所有存活的对象进行标记
-
清除
遍历整个堆内存,对于没有被标记的对象,将其内存区域进行回收
-
-
特点:
由于没有对内存进行进行整理,知识回收部分内存区域,所以会产生一些内存碎片,而且需要维护一个空闲列表来标记哪些区域是可用的
-
-
复制算法(需要准备两块内存区域,一块内存使用,一块内存为空复制时用)
-
执行流程:
-
标记
从GC Roots出发,对堆内存中的所有存活对象进行标记
-
复制
遍历堆内存,将其中存活的对象全部意义转移到另一块内存区域中,分配的是连续的内存空间。复制结束后交换两块内存空间的角色即可
-
-
特点:
实现简单,不会产生内存碎片,但是需要维护两块内存空间,同时在复制后需要改变对象的引用,这也是不小的一笔开销
-
-
标记-整理算法
-
执行流程
-
标记
从GC roots触发,对堆内存中的所有存活对象进行标记
-
整理
遍历整个堆内存,将所有存活的对象移动到内存的一端并连续存储
-
-
特点
不会产生内存碎片,但需要移动对象,改变对象的引用地址
-
-
分代收集算法
分代收集算法是基于在java中大多数对象都是朝生夕死的来设计的,也就是说对于哪些朝生夕死的对象分配一块内存区域,而长时间存活的对象分配到另一块区域,这样就不用每次都对整个堆进行回收,只需要对某一块儿区域进行回收即可。这样便能够提升垃圾收集的效率。
10 常见的垃圾收集器
年轻代:serial, ParNew, Parallel Scavenge
老年代:serial old, CMS, Parallel Old
两个代中都有:G1
11 CMS垃圾回收器
-
概述
CMS收集器是以获取最短回收停顿时间为目标的收集器,它在垃圾收集时使得用户线程和GC线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿
-
回收过程
-
初始标记
这一阶段是对GC Roots能直接关联到的对象进行标记,这一阶段会产生STW,但是由于与GC Roots直接关联的对象很少,所以这一阶段停顿时间也是非常小的
-
并发标记
从GC Roots能够直接关联到的对象开始遍历整个象图,并对遍历到的对象进行标记,这一阶段耗时比较长,但是是与用户线程并发执行的
-
重新标记
由于在并发阶段,用户线程仍然是在运行的,所以该阶段可能会产生新的垃圾,所以需要再次对并发标记阶段产生的新垃圾进行标记,这一阶段会产生STW。
-
并发清除
清理前面标记的垃圾,这一阶段耗时比较长,但是是和用户线程并发执行的,对于CMS来说,这一阶段只清理对象并不做整理
-
-
存在的问题
-
并发导致CPU资源紧张
并发标记和并发清除阶段 是和我们的用户线程并发执行,所以我们的CMS线程会占用一定的CPU资源
-
无法处理浮动垃圾
由于清理垃圾阶段和用户线程是并发执行的,在该阶段可能会产生新的垃圾,而这部分垃圾在该过程中是不会被知晓的
-
并发失败
由于垃圾回收期间用户线程还在运行,所以并不能等待老年代满了再回收,因为用户线程可能还会往老年代中增加对象,所以需要预留一定的空间给用户线程使用。若这部分预留空间不够用户线程使用,就会出现并发失败,这时候虚拟机就不得不STW使用serial old回收器来进行回收
-
内存碎片
CMS采用的是标记-清除算法进行垃圾回收,也就是不会对堆内存进行整理,这样会在堆中产生大量的内存碎片,就会导致在分配大对象内存空间时,虽然可用内存够但连续内存不够,从而触发full gc,从而导致长延迟。
-
12 G1垃圾回收器
G1是一款能够做到停顿时间和吞吐量都比较好的垃圾回收器,我们可以给G1一个指定的停顿时间,然后我们的G1会尽力去达到这个停顿时间。G1能够做到这些主要是因为他采用了分区分代回收的策略。
分区分代
将整个堆空间从逻辑上划分为若干个region,每个region都可以作为Eden,survivor,老年代和H区,这里需要说一下的就是H区,这是指专门用来存储大对象的区域,当对象的大小大于region的一半的时候就认为是大对象,当对象的大小大于region时,对象分配若干个连续的H区
记忆集
G1给每个region都维护了一个R set,也就是记忆集,该记忆集记录了哪些region指向当前的region,也就是记录了跨区引用的信息
垃圾回收过程
-
初始标记
该过程是对GC Roots能够直接关联到的对象进行标记,这一阶段存在STW,但是时间是非常短的
-
并发标记
从GC Root开始,进行可达性分析,标记当前还存活的对象,这一过程是和用户线程并发进行的
-
最终标记
在并发标记的过程中可能产生新的垃圾,所以需要进行最终标记,来标记并发标记过程中新加入GC Roots中的对象,这一过程是需要暂停用户线程的,也就是存在STW
-
筛选回收
这一阶段就是进行垃圾收集了,该过程需要暂停用户线程,也就是存在STW。该过程会更新region的统计数据,将各个region根据回收价值进行排序,然后根据用户设定的最大停顿时间来选择合适的region来进行垃圾回收,该过程是复制算法,也就是将要回收的region中的存活对象移到另外一个region中然后将原region全部回收。
13 方法调用
方法的分类
首先来说一下方法的分类,Java中的方法主要分为两个大类,即虚方法和非虚方法,具体介绍如下
-
非虚方法
非虚方法是指在类加载的解析阶段就会将方法的符号引用转换为直接引用的方法,这类方法主要由五种:
静态方法,私有方法,父类的方法,实例构造方法,final方法
-
虚方法
和非虚方法相对,除非虚方法以外的方法都是虚方法,这类方法只有在程序运行的过程中才能够确定是调用的哪一类方法
静态多态
静态多态也叫作编译期多态,也就是在编译阶段我们就能够确定调用的到低是哪个方法,在Java中静态多态就是指的方法的重载了,重载是说在一个类中我们可以定义一些方法名相同的方法,但是注意这些方法的参数列表一定不同,返回值可以不同。对于重载方法的调用,编译时虚拟机就能够根据方法传入的参数确定调用的究竟是哪一个方法
动态多态
动态多态也成为运行时多态,也是我们常说的Java多态,指的是一个父类的引用/接口的引用可以指向该父类/结构的一个子类/实现类。这样,对于一个对象的就有了两个概念:静态类型与运行时类型,举个例子:
//实例化
Father son = new Son();
//方法调用
son.method()
其中的Father就是son的静态类型,Son是son的运行时类型。当我们在调用方法的时候,确定所调用方法的过程如下:
- 首先确定调用方法实例的运行时类型,记为C
- 然后在类型C中找到与常量池中描述符和简单名称都相同的方法,然后进行访问权限的检查,检查通过则返回这个方法的直接引用,查找过程结束;不通过则抛出IllegalAccessError异常
- 若在类型C中没有找到相应的方法,则按照继承关系从下至上依次对C的各个父类进行第二步搜索和验证的过程
- 如果始终没有找到合适的方法,则抛出AbstractMethodError异常
这就是虚方法的整个调用流程,需要注意的是多态是针对方法的多态,对于字段并没有多态的说法,在编译期根据实例的静态类型就能够确定访问的是哪个字段
虚方法表
从前面对于虚方法的调用过程可以看到这是一个非常复杂的过程,倘若每次虚方法的调用都要经历这样的过程,显然是时分耗时的,所以就引入了虚方法表来进行性能优化。
虚方法表中存放了各个方法的实际地址入口。若某个方法在子类中没有被重写,那么子类虚方法表中的实际地址入口就和父类虚方法表中的地址入口是一样的,倘若方法被子类重写了,那么子类虚方法表中的方法实际入口地址就会被替换为子类的实现版本的入口地址
虚方法表一般来类加载的连接阶段进行初始化,准备了类的变量的初始值后,虚拟机就将该类的虚方法表一起初始化