1、JVM基础概念
(1)JVM、JRE、JDK
- JRE:JVM+基本类库组成的运行环境就是JRE。JVM自己是无法完成一次编译,处处运行的,需要有一个基本类库告诉JVM如何操作运行,如如何操作文件,连接网络等,JVM运行时,会一次性加载基本类库;
- JDK:JDK中除了包含JRE,同时还包含一些小工具,如javac,jar等;如果只要运行java程序,只需JRE即可
- 三者之间关系:JDK>JRE>JVM
(2)java程序运行
- 编写好一个java程序后,使用javac进行编译后,会生成字节码文件(java程序中,使用的输出等模块是JRE里提供的基本类);
- JVM采用栈架构,其指令由操作码(opcode)+操作数组成,操作码只有1个字节长度(0-255),指令数不能超过256条
- 我们运行.class文件时,会启动一个JVM进程,JVM通过opcode+操作数来执行程序;
- 执行方式有:解释执行:将opcode+操作数翻译为机器码文件;JIT:及时编译,在特定条件下,会将字节码编译为机器码文件之后才执行;
2、JVM内存管理
(1)JVM内存布局
- 堆中的数据是共享的,占用内存最大;
- 执行字节码文件的模块为执行引擎,线程切换通过程序计数器进行;
(2)虚拟机栈
- 虚拟机栈是基于线程的,main方法启动后,以线程方式运行;
- 线程的生命周期和栈的生命周期一样,栈中每一条数据都是栈帧;
- 每调用一个java方法,就会创建一个栈帧并入栈,执行完改该方法后,便会出栈;
- 所有栈帧出栈后,线程就会结束;
(3)程序计数器
- 程序计数器可以看作当前线程执行字节码的行号指示器,存储的是当前线程的进度;
- 程序计数器有线程创建时产生,配合虚拟机站完成计算操作;
(4)堆
- 申请的对象都是存储在堆中,垃圾回收的对象是堆;
- 堆在程序启动时创建,随着对象不断创建,堆空间会越来越小,就需要对堆内不常用的对象进行回收,即GC;
- java对象分为基本数据类型和普通对象,基本数据类型存储在栈中;对于普通对象,JVM会在堆中创建对象,其他地方使用该应用;
- 堆中的数据是线程共享的,栈中的数据是线程私有的;
(5)元空间
- Java8之前,类(创建对象的模板)都是存储在永久代中,且不会被JVM回收,随着类的增多,会造成JVM内存溢出;
- java8中,元空间替代了永久代,在非堆上存储,JVM不会再出现方法区的内存溢出,但无限使用元空间会造成操作系统挂掉,可以通过-XX:MaxMetaspaceSize控制元空间大小;
3、类加载
(1)类加载过程
- 加载:将.class文件加载到JVM的方法区中;
- 验证:判断.class是否可以加载进入JVM中,不合法的直接抛出异常;低版本的JVM无法加载高版本类库;
- 准备:为类变量分配内存,并初始化值;此时实例对象还未分配内存,所以此时是在方法区中进行的;如下图,类变量有两次赋值,一次在准备阶段(赋予默认值),一次在初始化阶段(赋予程序员指定的值),而局部变量不会初始化赋值,没有默认值,不指定会报错;
- 解析:将符号应用替换为直接应用。符号应用就是我们定义的字面量,如int a=1;a就是符号应用;直接应用是直接执行目标对象的指针或偏移量;
- 初始化:初始化成员变量;类信息只会存储一份到方法区中,类加载只会加载一次,所以static变量和staic代码块只会加载一次;JVM保证子类初始化之前,父类已经初始化;
如下代码,第一次new时执行顺序 :父类A的静态方法块---子类B的静态方法块--父类A的构造方法--子类B的构造方法;第二new时执行顺序 :父类A的构造方法--子类B的构造方法
public class A {
static {
System.out.println("A static 代码块");//只会执行一次
}
public A(){
System.out.println("A 构造方法");
}
}
class B extends A{
static {
System.out.println("B static 代码块");//只会执行一次
}
public B(){
System.out.println("B 构造方法");
}
public static void main(String[] args) {
A a = new B();//第一次会调用静态代码块
B b = new B();//不会调用静态代码块
}
}
(2)类加载器
- 双亲委派机制:除了顶层的启动类加载器外,其余加载器都会在加载类时,先委托给父类加载,父类无法加载,才会交给子类去加载;
- 双亲委派机制可以避免,我们自定义的类去覆盖java类库,保证java程序的安全;如下,我们使用写一个java.lang.String,运行main方法时,会报错,因为加载String类时是由父类加载器加载,加载的是java类库中的String,而java类库中的String类没有main方法,所以会报错;
- Tomcat中会破坏双亲委派机制,每一个web应用都有一个WebappClassLoader加载器,会加载应用程序类,只有当自己加载不到,才会委托给父类;使用该类加载器,JVM中出现同一个第三方库不同版本,应用程序去使用他不会发生冲突,他们由各自的类加载器加载,相互隔离(类对象是否相同由类加载器的命名空间和类对象本身决定,不同类加载器,命名空间不同);
4、JVM异常处理
(1)异常表
- 每一个方法都会携带一个异常表,表中每一行代表一个异常处理器,并且由from指针、to指针、target指针和异常处理类型组成;这些指针指向的是字节码的索引
- from指针到to指针表示异常监控的范围(try中代码),target指针表示异常起始处理器的开始位置,即catch代码,如下图:异常监控范围为0-7(不包括7),异常处理器开始范围为10;
(2)异常处理
- 程序出现异常时,JVM会开始从上到下遍历异常表中每一行,当出现异常的字节码索引在异常表中的某一行的监控范围内,则会判断监控表的异常类型是否和当前程序抛出的异常类型一致,如果一致,会将字节码索引转到target指针;
- 如果遍历完异常表的每一行,还是没有找到可以处理异常的异常处理器,则会将该方法的栈帧出栈,继续遍历调用该方法的调用者的异常表,重复异常表查找操作,最坏会遍历当前栈上所有方法的异常表;
(3)finally块
- finally代码块的内容会赋值到try和catch块中;
- 异常表中会生成一行异常处理条目,监控整个try-catch,并且捕获异常;
- 在执行完finally块后,若有捕获异常则会抛出异常;
5、OOM
(1)GC Root
- 发生GC回收时,对于每一个对象,JVM总是回去寻找引用他的祖先,如果这个引用祖先已经挂了,就会将该对象回收,能够躲避垃圾回收的祖先,就是GC root;
- 从GC root向下搜索,会产生一条Refrence Chain链,当任何一个对象不能和任何一个GC root产生关系时,就会被回收;
- GC过程是找出活的对象,并认定其他对象为”无用”;
GC Root有:
- java线程中,当前正在被调用的方法的引用类型参数、局部变量、临时值等,即和栈帧相关的各种引用;
- 所有被加载的java类;
- java类的引用类型的静态变量;
- 运行时常量池里的引用类型;(String或Class类型)
- 用于同步的监控对象,如调用了对象的wait()方法;
大致分为三类:
- 线程中的相关引用;
- 类静态变量的引用;
- JNI引用;
(2)引用类型
- 强引用:当内存不足的时候,JVM会抛出OutOfMemoryError错误,即使程序终止,该对象也不会被回收;只有将GC Root与其断开,该对象才会被回收;如:我们平常new一个对象即为一个强引用,大量对象被创建且不能被回收时,会造成内存泄漏;
- 软引用:维护一些可有可无的对象,只有当堆内存空间不够时,才会回收该对象,如果回收了软引用后,JVM内存空间任然不足,则会抛出OutOfMemoryError;软引用可以和引用队列(ReferenceQueue)联合使用,当软引用所引用的对象被回收后,就会加入到该队列中;
- 弱引用:GC回收时,不管空间是否足够,都会回收弱引用所指向的对象;与WeakRefenece联合使用;
- 虚引用:采用该引用的对象,在GC回收时,都会被回收;虚引用必须和引用队列联合使用。当GC回收对象时,若该对象有虚引用,则会将虚引用加入到引用队列中,在对象被回收之前,会执行一些逻辑;
(3)OOM原因
- OOM可能发生的区域有堆、本地方法栈、虚拟机栈、元空间;
- 内存容量过小,需要调整堆大小;
- 错误的引用方式,发生内存泄漏。没有及时清理与GC Root相关联系,如线程池中的线程,复用时没有清理ThreadLocal,会造成内存泄漏;
- 堆接口参数没有校验,传入参数超出范围;
- 堆外内存无限制使用,会导致操作系统资源耗尽;
6、垃圾回收
(1)垃圾回收算法
- 标记清除算法:根据GC Root遍历所有可达对象,进行标记,清除掉未标记的对象,该算法会造成内存碎片,如下,当GC回收后,剩余总空间还有7K,分配一个6K空间,无法分配成功
- 标记复制算法:会预留一半的空间,每次GC回收时,会将活对象复制到空闲的空间中去,所有算法中效率最高,但会造成空间浪费
标记整理:GC回收时,会将存活对象整理到一起,不会造成空间浪费,同时也不会出现内存碎片,但效率最低;
(2)年轻代
- 存活时间比较短的对象都存储在年轻代中;
- 使用的清除算法是标记复制算法,因为年轻代中存活的对象比较少,使用标记复制算法可以大大提高效率;
- 年轻代分为伊甸园去(Eden)和两个幸存区(Survivor),对象会先分配到伊甸园区;
- 当Eden区分配满时,会触发年轻代GC(Minor GC),过程如下:
-
- Eden第一次GC,先将Eden区存活的对象移动到一个幸存区;
- Eden再次GC,会采用标记复制算法,会将清理from区和Eden区,存活的对象被移动到To区,最后将From区清空即可;
- Eden:From:To=8:1:1,会浪费10%的空间;-XX:SurvivorRatio可以设置(默认为8);
- JVM会为每一个线程分配一个缓冲区(TLAB),TLAB在Eden区中,每个线程可以在TLAB上分配对象,避免锁竞争,但如果分配对象大小超过TLAB大小,则会在Eden区的公共区域分配;
(3)老年代
- 老年代一般采用标记清除、标记整理算法(Major GC),因为对象存活比较久,空间占用率比较大,拷贝不划算;
- 对象进入老年代途径:
-
- 提升:根据对象的年龄判断是否需要进入老年代,每发生一次Minor GC,存活下来的对象年龄会加1,直到年龄超过阈值,对象就会进入老年代;若对象不可达,会等到老年代发生GC时,才会被回收;阈值,可以通过参数 ‐XX:+MaxTenuringThreshold 进行配置,最大值是 15
- 分配担保:年轻代中每次存活的对象都会进入Survivor区中,这个区域只有10%的大小,但我们无法保证每次存活对象的大小不超过10%,当Survivor空间不够时,就需要依赖老年代进行担保,直接在老年代中分配对象;
- 大对象直接分配到老年代:当对象大小超过指定大小,会直接在老年代中分配对象;通过设置该参数 -XX:PretenureSizeThreshold,默认为0(即对象都在年轻代分配);
- 动态对象年龄判断:当幸存区中相同年龄对象的大小的和超过幸存区的一半,幸存区中大于等于这个年龄的对象都会进入老年代;
(4)年轻代垃圾回收器
- Serial 垃圾收集器:处理GC只有一个线程,GC时会停止一切用户进程,通常用于客户端应用,因为客户端应用通常不会建立过多对象;
- ParNew垃圾收集器:处理GC时有多个线程,GC时同样会停止用户线程,存在线程切换开销,在单CPU下,性能比Serial差;追求降低用户停顿时间;
- Paraller Scavenge垃圾收集器:也是多线程处理,追求CPU高吞吐量,能够在较短时间内完成指定任务;
(5)老年代垃圾收集器
- Serial Old垃圾回收器:同样为单线程版本,采用标记整理算法,而年轻代中采用标记复制算法;
- Parallel Old垃圾回收器:多线程处理,追求CPU高吞吐;
- CMS垃圾回收器:以获取最短用户停顿时间为目标的垃圾收集器,采用用户线程和回收线程并发执行,用户不会感觉到明显的停顿;
7、CMS垃圾收集器
- CMS全称并发标记清除垃圾回收器,在年轻代使用标记复制算法,在老年代使用标记清除算法,会造成内存碎片,只有通过full GC时,进行整理;
- 主要是为了降低清除老年代时,避免长时停顿;
- UseCMSCompactAtFullCollection:默认开启,表示Full GC时,需要整理内存,不能和用户进程并发,会造成停顿;
回收过程如下:
- 初始标记:只标记和GC Root直接相连的对象,因为GC Root在追踪活对象时最为耗时,同时还要标记与年轻代相关的对象;会发生STW;
- 并发标记:在初始标记阶段,进行并发标记(标记所有可达对象)。该阶段最为耗时,但可以和用户进程并行。在该阶段,可能会有对象引用发生变化,引用发生变化的对象会被标记为dirty,用于后续重新扫描;
- 并发预清理:并发预清理被标记为dirty状态的对象,将dirty状态对象重新标记,清除dirty状态,该阶段和用户进程并发,因此可能还会出现dirty状态;
- 并发可取消的预清理:重新标记阶段是需要STW(停止用户线程),因此在满足某些条件时,可以终止标记,避免会扫年轻代大量对象;
- 最终标记:第二次STW,标记所有存活对象。之前的并发预清理阶段可能发生多次,可能赶不上应用变化清况,所以最终标记会STW,停止应用进程,最终标记所有存活对象;
- 并发清除:与用户进程并发进行,清除所有不可达对象,可能会产生新的垃圾(浮动垃圾),该部分垃圾只能等到下一次GC时才能被回收;
- 并发重置:重置与CMS相关的内部结构,为下一次GC做准备;