JVM基础
JVM
JVM(执行字节码,转为机器码)
Java虚拟机
概述
- Java虚拟机是一台执行
Java字节码
的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成。 - JVM平台的各种语言可以共享Java虚拟机带来的跨平台性、优秀的垃圾回收器,以及可靠的即时编译器。
- Java技术的核心就是Java虚拟机(JVM,Java Virtual Machine),因为所有的Java程序都运行在Java虚拟机内部。
作用
Java虚拟机就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条Java指令,Java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。
特点
- 一次编译,到处运行
- 自动内存管理
- 自动垃圾回收功能
JVM的整体结构
- 类加载器
- 运行时数据区
- JVM 定义的Java 程序运行期间需要使用到的内存区域,简单来说,这块内存区域存放了字节码信息以及程序执行过程的数据。
- 执行引擎
使用基于栈的指令集架构
基于栈的指令集架构(了解)
基于栈式架构的特点:
- 设计和实现更简单,适用于资源受限的系统;
- 避开了寄存器的分配难题:使用
零地址
指令方式分配 - 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现
- 不需要硬件支持,可移植性更好,更好实现跨平台
基于寄存器的指令级架构(了解)
基于寄存器架构的特点:
- 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机。
- 指令集架构则完全依赖硬件,与硬件的耦合度高,可移植性差
- 性能优秀和执行更高效
- 花费更少的指令去完成一项操作
- 在大部分情况下,基于寄存器架构的指令集往往都以
一地址指令、二地址指令和三地址指令
为主,而基于栈式架构的指令集却是以零地址指令为主
JVM的生命周期
虚拟机的启动
Java虚拟机的启动是通过引导类加载器
(bootstrap class loader)创建一个初始类
(initial class)来完成的,这个类是由虚拟机的具体实现指定的
虚拟机的执行
- 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序
- 程序开始执行时他才运行,程序结束时他就停止
- 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程
虚拟机的退出
有如下的几种情况:
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统用现错误而导致Java虚拟机进程终止
- 某线程调用Runtime类或System类的
exit()
方法,或Runtime类的halt()
方法,并且Java安全管理器也允许这次exit()或halt()操作。 - 除此之外,JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载 Java虚拟机时,Java虚拟机的退出情况。
类加载过程
加载
- 通过一个类的全限定名获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
链接
验证
- 目的在于确保 Class 文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
- 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
准备
- 为类变量分配内存并且设置该类变量的默认初始值,即零值。
- 这里不包括用 final 修饰的 static,因为 final 在编译的时候就会分配了,准备阶段会显示初始化。
- 这里不会为实例变量分配初始值,类变量会分配在方法区中,而实例变量是会随着对象一起分配到 Java 堆中。
解析
- 将常量池内的符号引用转换为直接引用的过程。
- 事实上,解析操作往往会伴随着 JVM 在执行完初始化之后再执行。
- 符号引用就是一组符号来描述所引用的目标,符号引用的字面量形式明确定义在《Java 虚拟机规范》的 Class 文件格式中。直接引用就是直接指向目标的指针,相对偏移量或一个间接定位到目标的句柄。
- 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的 CONSTANT_Class_info、CONSTANT_Filedref_info、CONSTANT_Methodref_ref 等。
初始化
- 初始化阶段就是执行构造方法 clinit() 的过程。clinit() 不同于类的构造器。(关联:类的构造器是虚拟机视角下的 init())
- 此方法不需定义,是 javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
- 构造器方法中指令按语句在源文件中出现的顺序执行。
- 若该类具有父类,JVM 会保证子类的 clinit() 执行前,父类的 clinit() 已经执行完毕。
- 虚拟机必须保证一个类的 clinit() 方法在多线程下被同步加锁。
Java类的加载过程是动态的,它不会一次性把程序所有的类全部加载后再运行,而是先保障程序运行的基础类加载到JVM虚拟机当中,其他的类,一般是再需要的时候才会去加载,这样的运行机制也达到了节约内存的目的。
当JVM虚拟机加载某个class文件的时候,采用的是双亲委派模式(任务委派模式),就是将请求交给父类去处理。
双亲委派机制
概念
双亲委派机制是指当一个类加载器收到某个类加载请求时,该类加载器首先会把请求委派给父类加载器。每个类加载器都是如此,它会先委托父类加载器在自己的搜索范围内找不到对应的类时,该类加载器才会尝试自己去加载。
工作流程
- Application ClassLoader 收到一个类加载请求时,首先它自己不会先去尝试加载这个类,而是先将这个加载请求委派给父类加载器Extension ClassLoader去加载。
- 如果Extension ClassLoader收到一个类加载请求时,先将加载请求委派给父类加载器Bootstrap ClassLoader去完成。
- 如果Bootstrap ClassLoader加载失败(在
\lib中未找到所需类),就会让Extension ClassLoader尝试加载;如果加载成功了就不再让Extension ClassLoader加载,过程结束。 - 如果Extension ClassLoader也加载失败,就会使用Application ClassLoader加载;如果加载成功了就不再让Application ClassLoader加载,过程结束。
- 如果Application ClassLoader也加载失败,就会使用自定义加载器去尝试加载。
- 如果所有的加载都失败了,就会抛出ClassNotFoundException异常。
理解:执行的情况都是由Bootstrap ClassLoader先加载,失败了轮到Extension ClassLoader加载,再失败了轮到Application ClassLoader,最后轮到自定义加载器加载。一般情况下大家写的java程序都是Application ClassLoader进行加载的。
优点
- 安全性高:
- 防止核心类库被篡改:通过双亲委派机制,核心类库(如
java.lang.*
包)由启动类加载器(Bootstrap ClassLoader)加载,避免被自定义类加载器加载和篡改。
- 防止核心类库被篡改:通过双亲委派机制,核心类库(如
- 类一致性:
- 防止重复加载:双亲委派机制确保一个类在整个应用程序中只有一个类加载器加载,避免了类的重复加载问题,从而保证了Java类的类型一致性。
- 简化类加载逻辑:
- 通过委派机制,类加载器之间的关系更加清晰明了,减少了类加载器之间的复杂依赖关系,简化了类加载器的实现。
- 类加载的层次结构:
- 维护了一个清晰的类加载层次结构,有助于代码的模块化和分层设计。
缺点
- 灵活性不足:
- 对于某些特殊需求,双亲委派机制可能显得不够灵活。比如,在需要重载核心类库时,双亲委派机制会阻碍这种操作,因为它总是优先使用父类加载器加载的类。
- 调试复杂:
- 当类加载出现问题(如类冲突或加载失败)时,由于双亲委派机制的层级关系,调试和排查问题可能变得更加复杂。
- 性能问题:
- 在某些场景下,频繁的类加载请求可能会产生性能开销,因为每次加载请求都需要经过父类加载器的检查,这可能增加加载时间。
自定义类加载器使用场景
- 网络下载类,某些应用可能需要动态从网络加载类,自定义类加载器可以实现这一功能。
- 应用安全性,自定义类加载器可以实现自定义的类加载策略和安全检查,控制加载哪些类以及如何加载,提升应用的安全性。
破坏双亲委派机制:
- 可以⾃⼰定义⼀个类加载器,重写loadClass方法;
- Tomcat 可以加载自己目录下的 class 文件,并不会传递给父类的加载器;
- Java 的 SPI,发起者 BootstrapClassLoader 已经是最上层了,它直接获取了 AppClassLoader 进行驱动加载,和双亲委派是相反的。
tomcat的类加载机制
步骤:
- 先在本地cache查找该类是否已经加载过,看看 Tomcat 有没有加载过这个类。
- 如果Tomcat 没有加载过这个类,则从系统类加载器的cache中查找是否加载过。
- 如果没有加载过这个类,尝试用ExtClassLoader类加载器类加载,重点来了,这里并没有首先使用 ApplicationClassLoader 来加载类。这个Tomcat 的 WebAPPClassLoader 违背了双亲委派机制,直接使用了 ExtClassLoader来加载类。这里注意 ExtClassLoader 双亲委派依然有效,ExtClassLoader 就会使用 Bootstrap ClassLoader 来对类进行加载,保证了 Jre 里面的核心类不会被重复加载。 比如在 Web 中加载一个 Object 类。WebAppClassLoader → ExtClassLoader → Bootstrap ClassLoader,这个加载链,就保证了 Object 不会被重复加载。
- 如果 BoostrapClassLoader,没有加载成功,就会调用自己的 findClass 方法由自己来对类进行加载,findClass 加载类的地址是自己本 web 应用下的 class。
- 加载依然失败,才使用 AppClassLoader 继续加载。
- 都没有加载成功的话,抛出异常。
总结一下以上步骤,WebAppClassLoader 加载类的时候,故意打破了JVM 双亲委派机制,绕开了 AppClassLoader,直接先使用 ExtClassLoader 来加载类。
JVM内存划分
JVM运行时数据区域
运行时数据区: 堆,方法区(元空间),程序计数器,本地方法栈,虚拟机栈
Heap(堆):
对象实例和数组的内存在堆上进行分配,是一块线程共享区域,用来存放对象实例,也是垃圾回收(GC)的主要区域;开启逃逸分析后,某些未逃逸的对象可以通过标量替换的方式在栈中分配;
堆细分:新生代、老年代,对于新生代又分为:Eden区和Surviver1和Surviver2区;
方法区(永久区):
存储已经被JVM加载的类信息,常量,静态变量;线程共享;Jdk1.8以后取消了方法区这个概念,称之为元空间(MetaSpace);
当应用中的 Java 类过多时,比如 Spring 等一些使用动态代理的框架生成了很多类,如果占用空间超出了我们的设定值,就会发生元空间溢出;
虚拟机栈:
线程私有;生命周期和线程的生命周期一起。里面存放的是一个个栈帧(单位),每个方法在执行的时候都会创建一个栈帧,用来存放局部变量表,操作数栈,动态链接,返回地址;
在Java虚拟机规范中,对此区域规定了两种异常状况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverflowError异常(栈溢出);
- 如果虚拟机栈动态扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常(内存溢出OOM)。
本地方法栈:
线程私有;与虚拟机栈类似,不同的是虚拟机栈服务的是Java方法,而本地方法栈服务的是Native方法。在HotSpot虚拟机实现中是把本地方法栈和虚拟机栈合二为一的,同理它也会抛出StackOverflowError和OOM异常。
PC程序计数器:
线程私有;PC指的是存放下一条指令的位置的一个指针;它是一块较小的内存空间;由于线程的切换,CPU在执行的过程中,需要记住原线程的下一条指令的位置,所以每一个线程都需要有自己的PC。
堆内存分配策略
对象优先分配在Eden区,如果Eden区没有足够的空间进行分配时,虚拟机执行一次MinorGC。而那些无需回收的存活对象,将会进到 Survivor 的 From 区(From 区内存不足时,直接进入 Old 区)。
大对象直接进入老年代(需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄(Age Count)计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,直到达到阀值(默认15次),对象进入老年区。
(动态对象年龄判定:程序从年龄最小的对象开始累加,如果累加的对象大小,大于幸存区的一半,则将当前的对象 age 作为新的阈值,年龄大于此阈值的对象则直接进入老年代)
每次进行Minor GC或者大对象直接进入老年区时,JVM会计算所需空间大小如小于老年区的剩余值大小,则进行一次Full GC。
创建一个对象的步骤
步骤:类加载检查、分配内存、初始化零值、设置对象头、执行init方法
JVM垃圾回收
1. JVM判断对象是否存活的算法
GC 的存活标准知道哪些区域的内存需要被回收之后,我们自然而然地想到了,如何去判断一个对象需要被回收呢?对于如何判断对象是否可以回收,有两种比较经典的判断策略。
- 引用计数算法
- 可达性分析算法
引用计数算法(Reference Counting Collector)
一个对象被创建之后,系统会给这个对象初始化一个引用计数器,当这个对象被引用了,则计数器 +1,而当该引用失效后,计数器便 -1,直到计数器为 0,意味着该对象不再被使用了,则可以将其进行回收了。
**优点:**实现简单,判定效率也很高
**缺点:**他很难解决对象之间相互循环引用的问题,基本上被抛弃
可达性分析法
根搜索算法的中心思想,就是从某一些指定的根对象(GC Roots)(虚拟机栈帧引用,静态变量引用,JNI引用的对象)出发,一步步遍历找到和这个根对象具有引用关系的对象,然后再从这些对象开始继续寻找,从而形成一个个的引用链(其实就和图论的思想一致),然后不在这些引用链上面的对象便被标识为引用不可达对象,也就是我们说的“垃圾”,这些对象便需要回收掉。这种算法很好地解决了上面 引用计数算法 的循环引用的问题了。
两次标记过程:
对象被回收之前,该对象的finalize()方法会被调用;两次标记,即第一次标记不在“关系网”中的对象。第二次的话就要先判断该对象有没有实现finalize()方法了,如果没有实现就直接判断该对象可回收;如果实现了就会先放在一个队列中,并由虚拟机建立的一个低优先级的线程去执行它,随后就会进行第二次的小规模标记,在这次被标记的对象就会真正的被回收了。
finalize()的定义和作用
Java 允许定义这样的方法,它在对象被垃圾收集器析构(回收)之前调用,这个方法叫做 finalize( )
,它用来清除回收对象。 不建议用finalize()
方法完成“非内存资源”的清理工作,但建议用于:
① 清理本地对象(通过JNI创建的对象);
② 确保某些非内存资源(如Socket、文件等)的释放:在finalize()
方法中显式调用其他资源释放方法。
2. 常用的垃圾回收算法
标记 - 清除算法(Tracing Collector)
标记出所有需要回收的对象,在标记完成后统⼀回收所有被标记的对象
缺点:效率低,标记清除后会产⽣⼤量不连续的碎⽚,需要预留空间给分配阶段的浮动垃圾
标记 - 整理算法(Compacting Collector)
标记过程仍然与“标记-清除”算法⼀样,再让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存;
解决了产生大量不连续碎片问题
复制算法(Copying Collector)
将内存分为⼤⼩相同的两块,每次使⽤其中的⼀块。当这⼀块的内存使⽤完后,就将还存活的对象复制到另⼀块去,然后再把使⽤的空间⼀次清理掉。这样就使每次的内存回收都是对内存区间的⼀半进⾏回收;
适应性算法(Adaptive Collector)
适应性算法 其实不是一种单独的回收算法,他只是一种智能选择回收算法的机制,也就是该算法会根据堆内存具体的使用情况而自动选用更适合当前情况的回收算法。
分代回收算法
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。
分代回收算法
根据各个年代的特点选择合适的垃圾收集算法。
- 新生代采用复制算法,新生代每次垃圾回收都要回收大部分对象,存活对象较少,即要复制的操作比较少,一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。
- 老年代的对象存活⼏率是⽐较⾼的,⽽且没有额外的空间对它进⾏分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进⾏垃圾收集。当老年代内存满时触发
Major GC
即Full GC
,Full GC
发生频率比较低,老年代对象存活时间比较长,存活率标记高。
对象如果选择进入那个代参考堆内存分配策略