Java虚拟机(JVM, Java Virtual Machine)是运行Java应用程序的核心组件,它是一个抽象化的计算机系统模型,为Java字节码提供运行环境。JVM的主要功能包括:类加载机制、内存管理、垃圾回收、指令解释与执行、异常处理与安全检查、性能优化等。
目录
一、初识JVM
1.1、JVM基础知识
1.什么是JVM?
JVM是java虚拟机,本质上是运行在计算机上的程序,它的职责是运行Java字节码文件。
正常我们编写的是.java文件,通过javac进行编译成.class的字节码文件,然后由JVM进行加载并解释成为机器码。
2.JVM的三大功能?
JVM的第一个作用是类加载并对对字节码文件的指令进行解释成机器码。
JVM的第二个功能是内存管理,主要包括为对象分配内存,以及垃圾回收等。
JVM的第三个功能是即时编译,对热点代码进行优化,提升执行效率。
java语言是先编译成字节码文件,再通过jvm解释成可执行的机器码文件。C/C++是直接编译链接成可执行的.exe文件,所以一般来说C/C++语言的性能更高。
Java语言的实时解释主要是为了实现跨平台特性,相当于一次编译成字节码文件,可以不同的平台通过JVM进行解释运行。
3.常见的JVM有哪些?
常见的虚拟机有如下几种,其中HotSpot是JDK默认自带的虚拟机。
1.2、JVM的组成部分
JVM主要包括如下几部分:类加载器、运行时数据区、执行引擎、本地接口四个部分。
1.类加载器:加载class字节码文件中的内容到内存中。
2.运行时数据区:负责管理JVM所使用的内存,比如创建对象和销毁对象。
3.执行引擎:将字节码中的文件解释成机器码,同时使用即时编译器优化性能。
4.本地接口:调用本地已经编译的方法,比如虚拟机中提供的c/c++方法。
其中运行时数据区包括大部分:
- 堆:存储对象实例和数组,是所有线程共享的一块区域,也是垃圾回收的主要区域。
- 虚拟机栈:每个线程都有自己的栈空间,用于存放基本类型的变量、对象引用以及方法调用的上下文信息(如局部变量表、操作数栈等)。
- 方法区:存储类元数据(如类的结构信息)、静态变量、常量池和即时编译后的代码等。
- 程序计数器:记录当前线程执行字节码的位置。
- 本地方法栈:为JNI调用的本地方法服务。
二、字节码文件详解
2.1、字节码文件的基本组成
注意:直接使用记事本打开字节码文件会是乱码,一般来说使用jclasslib工具可以查看字节码文件
字节码文件主要包含如下5个部分:
1.基础信息:魔数、字节码对应的Java版本号、访问标识、父类与接口。
2.常量池:保存了字符串常量、类或者接口名主要在字节码指令中使用。
3.字段:当前类或者接口声明的字段信息。
4.方法:当前类或者接口声明的方法信息。
5.属性:类的属性,比如源码的文件名、内部类的列表等。
2.2、类加载器
1.类的生命周期?
加载->连接(验证、准备、解析)->初始化->使用->卸载
加载:类加载器根据类的全限名通过不同的渠道以二进制流的方式获取字节码信息。类加载完成后JVM会把字节码信息放到方法区中。Java虚拟机还会在堆中生成一份与方法区中类似的java.lang.Class。
连接:验证阶段是验证内容是否满足java虚拟机规范,准备阶段是静态变量赋初值,解析阶段是将常量池中的符号引用替换成指向内存的直接引用。
验证:
1.对文件 格式的校验以及主次版本号是否满足当前Java虚拟机版本要求。例如:主版本号不允许超过运行环境的版本号,如果主版本号相等,副版本号也不能超过。
2.元信息验证,比如类必须有父类。
3.验证程序执行指令的语义。
4.符号引用验证,例如是否访问了其他类中private修饰的方法。
准备:
final修饰的基本数据类型的静态变量,准备阶段会对变量赋终值。
解析:
将常量池中的符号引用替换为直接引用。
符号引用:在字节码文件中使用编号来访问常量池中的内容。
直接引用:不再使用编号,使用内存中的地址进行具体数据的访问。
初始化:
初始化阶段会执行静态代码块中的代码,并为静态变量赋初值。本质上就是初始化阶段会执行字节码文件中cliniit部分的指令。
以下几种方式会导致类的初始化:
1.访问一个类的静态变量或者静态方法,注意final修饰的常量不会触发初始化 。
2.调用Class.forName()
3.new一个该类的对象时
4.执行Main方法中的当前类
2.常见的类加载器有哪些?
类加载器是Java虚拟机提供给应用程序去实现获取类或接口的字节码数据的技术。
启动类加载器(BootStrap ClassLoader):用于加载一些核心类,由HotSpot虚拟机提供,使用 C++编写的类加载器。
扩展类加载器器:用于加载一些扩展类,由JDK提供,使用Java编写的类加载器。
应用程序类加载器:用于加载应用第三方jar包里的类,由JDK提供,使用Java编写的类加载器。
3.双亲委派机制的作用,什么是双亲委派机制?
作用:通过双亲委派机制避免恶意代码替换JDK中的核心类库,确保核心类库的完整性和安全性,另外可以避免同一个类被多次加载。
双亲委派机制:类加载器在加载之前会向上查找其父类是否加载过,如果父类加载过则直接返回,父类能未加载过则判断能否加载(是否在类加载器的加载目录路径中),能加载就加载,不能加载则由子类加载。
4.如何打破双亲委派机制?为什么要打破双亲委派机制?
打破双亲委派机制的三种方式:
1.自定义类加载器并重写LoadClass方法,就可以把双亲委派机制的代码清除
2.利用上下文类加载器加载类,例如:JDBC和 JNDI等
3.利用Osgi框架的类加载器
- 实现特殊的需求:在某些情况下,可能需要加载特殊的类或者自定义类加载逻辑,例如实现热替换(HotSwapping)功能,或者实现隔离的类加载环境,如OSGi、Tomcat容器等,这些场景下需要自定义类加载器并改变类加载的顺序和规则。
- 实现版本隔离或多重类加载:在某些框架或应用服务器中,为了支持多个版本的同一类库共存,或者为了实现模块化加载,需要打破双亲委派,让每个模块拥有独立的类加载器来加载自己的类。
三、JVM的内存区域
内存溢出(OOM):程序在使用某一块内存区域时,存放的数据占用内存大小超出了虚拟机所能提供的最大内存上限。
1.Java的内存区域?
程序计数器(线程不共享):每个线程通过程序计数器记录当前要执行的字节码指令的地址。在程序执行过程中,程序计数器会记录下一行字节码指令的地址,执行完当前指令后,java虚拟机的执行引擎会根据指令计数器执行下一行 指令。具体来说程序计数器有两个作用:1.记录指令执行的地址并控制线程执行指令。2.多线程执行指令,线程切换时候用来记录线程执行到指令的地址。
程序计数器不会产生内存溢出,因为每个线程只是存储一个固定长度的内存地址。
Java虚拟机栈(线程不共享):Java虚拟机栈随着线程的创建而创建,随着线程的销毁而回收。java虚拟机栈存放的是栈帧,栈帧包含三部分:局部变量表、操作数栈、帧数据。
局部变量表:程序在运行过程中,存放执行方法的所有局部变量。局部变量表本质上是一个数组,数组中每个位置称为一个槽,long和double占2个槽,其它类型占1个槽。局部变量保存的内容:实例方法的this,方法的参数,方法体中声明的局部变量。为了节省空间,局部变量表的槽是可以复用的,一旦某个局部变量失效,当前的槽就可以被复用。
操作数栈:是虚拟机在执行指令的过程用来存放临时数据的一块区域。比如:当前指令的值压入操作数栈中,后面的指令可以弹出并使用该值。在编译时期就可以确定操作数栈的最大深度,从而在执行的时候正确分配内存大小。
帧数据:动态链接、方法出口、异常表的引用。动态链接用于保存符号引用到运行时常量池内存地址的一个映射关系。方法出口存放的是栈帧中下一个指令的执行地址。异常表存放的是代码中异常的处理信息,包括异常捕获的生效范围以及发生异常后跳转到字节码指令的位置。
要修改 Java虚拟机栈的大小,可以使用虚拟机参数-Xss,例如:-Xss256k
虚拟机HotSpot对栈的最大值与最小值有要求,例如:windows下jdk8的运行虚拟机栈的范围在180k-1024m之间。另外,局部变量过多或者操作数栈深度过大也可能影响栈内存的大小。
本地方法栈(线程不共享):和Java虚拟机栈类似,用于服务native方法。Java虚拟机栈存储的是Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。在HotSpot虚拟机中,Java虚拟机栈和本地方法栈使用同一块内存空间。
堆(线程共享):创建出来的对象存在堆内存中,堆内存的大小是有上限的,当一直向堆中放入对象达到上限之后就会抛出OutOfMemory(OOM)的错误。要修改虚拟机堆的大小可以使用参数:
-Xmx值(max最大值) -Xms值(初始的total) 建议将这两个值设置为相同大小的,这样程序在启动的时候使用的总的内存就是最大的内存,无需向Java虚拟机再次申请,减少堆内存申请的开销。
方法区(线程共享):主要存放三部分的信息:1.类的基本信息,2.运行时常量池:字节码文件的常量池内容,3.字符串常量池内容。在类加载阶段,方法区会存放类的基本信息。JDK7及之前的版本将方法区放在堆内存中的永久代中,堆的大小由虚拟机控制;JDK8及之后的版本将方法区放到了元空间中,元空间位于操作系统维护的直接内存中,默认只要不超过操作系统承受的上限,则可以一直分配。运行时常量池主要用于存储编译期生成的各种字面量和符号引用。字符串常量池主要用于存储字符串字面量的对象引用地址的一个区域。
注意:String类型的字符串,如果是变量拼接,则转换成StringBuilder,如果是常量拼接,则直接拼接。
直接内存:JDK8及其以后方法区就存在直接内存的元空间中。
四、JVM的垃圾回收机制
4.1、自动垃圾回收
内存泄漏:指的是不再使用的对象在系统中未被回收,内存泄漏的累积可能会导致内存溢出。
在C/C++这类语言中没有自动垃圾回收机制,一个对象不再使用,需要手动释放,否则会导致内存泄漏 。
Java为了简化对象的释放,引入了自动垃圾回收机制,通过垃圾回收器来对不再使用的对象进行回收,垃圾回收器主要是对堆上的内存进行回收。
4.2、方法区的回收
我们知道类的声明周期包括:加载,连接(验证,准备,解析),初始化,使用,卸载五个部分,其中最后一步的卸载就是方法区的回收。
判断一个类是否可以被卸载需要同时满足以下三个条件:
1.此类的所有实例对象都已经被回收,在堆中不存在该类的任何实例对象以及子类对象。
2.加载该类的类加载器已经被回收。
3.该类对应的java.lang.Class对象在任何地方都没有被引用。
如果需要手动回收垃圾,需要使用 System.gc()方法
4.3、堆内存的回收
1.常见的垃圾回收算法?
引用计数法与可达性分析法
引用计数法:为每个对象维护一个引用计数器,当对象被应用则加1,取消引用则减1。该方法优点是十分简单。缺点:存在循环引用的问题,A引用B且B也引用A,假如应用程序不再需要这两个对象,但是 引用 计数器不为0,所以导致这两个对象无法回收,会出现内存泄漏。
可达性分析法:Java使用的是可达性分析算法来判断对象是否可以回收,可达性分析将对象分为两类:垃圾回收的根对象(GC Root)与普通对象,对象与对象之间存在引用关系。如果从普通对象到根对象的引用链是不可达的,则说明该对象则是可回收的。
那么哪些对象可以被称为是GCRoot对象呢,以下四类可以被称为垃圾回收根对象:
1)线程Thread对象
2)系统类加载器加载的 java.lang.Class对象
3)监视器对象,用来保存同步锁synchronized关键字持有的对象
4)本地方法调用时使用的全局对象
标记清除法:在可达性分析算法确定了哪些对象是可达(存活)之后,垃圾收集器将对那些不可达的对象进行标记操作。标记完成后,垃圾收集器会清除所有被标记为不可达的对象占用的内存空间。优点:实现简单,第一阶段进行标记,第二阶段进行清除即可。缺点:内存碎片化,清除掉一些碎片的内存可能导致无法再分配空间,维护链表进行分配,分配速度慢。
标记复制法:当前存活的对象复制到另一块空间,将当前的空间进行清理掉。优点:吞吐量高,复制后不会发生碎片化。缺点:内存空间的使用率低 。
标记压缩法:也称为标记整理算法,使用可达性分析标记存活的对象,将存活的对象移动到内存的一侧,清理掉存活对象之前的内存空间。优点:内存使用率高,不会产生碎片化。缺点:整理算法的效率不高。
分代GC算法:分代垃圾回收将整个内存区域划分成年轻代与老年代。
年轻代:该区域包含三个部分:伊甸园区Eden,幸存者区S0,幸存者区S1。
创建出来的对象首先会放入伊甸园区,如果Eden区满了,就会触发young GC,如果可以被回收,则回收,否则放入幸存者区,如果年轻代的对象一直没回收,存活时间比较长的对象会被放入到老年代。当老年代不足的时候,且年轻代也满了,无法放入新的对象就会触发 full GC。
2.常见的垃圾回收器?
为什么分代GC算法要把堆内存空间 划分成年轻代与老年代?
因为年轻代用于存放可以很快被回收的对象,比如:用户订单数据。老年代则存放长期存活的对象,比如 Spring的大部分Bean对象。在虚拟机的默认设置中,新生代的大小 要远小于老年代的大小。总的来说,分代GC算法将堆分成新生代和老年代的主要原因可以分为如下三点:
1.可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
2.新生代和老年代选择不同的垃圾回收算法,新生代一般选择标记复制法,老年代一般选择标记清除法与标记压缩法,由程序员来选择,灵活度较高。
3.分代的设计只允许回收新生代,如果能满足对象分配的要求就不需要对整个堆进行回收,即不需要full GC。
常见的垃圾回收器:Serial、ParNew、Parallel Scavenge、CMS、Serial Old、Parallel Old
Serial是一种单线程串行的年轻代垃圾回收器,使用的是标记复制法,适用于单cpu进行处理垃圾回收,不适用于多cpu下的处理,多cpu下可能会使得其它用户线程处于长时间的等待状态。
SerialOld是Serial的老年代版本,采用单线程串行回收,可以与Serial垃圾回收器搭配使用。
ParNew是年轻代的垃圾回收器,本质上是堆Serial进行多cpu下的优化,使用多线程进行垃圾回收,与老年代垃圾回收器CMS进行搭配使用。
CMS垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时进行,减少了用户线程的等待时间。采用标记清除法,垃圾回收停顿的时间短,用户体验好,但是存在内存碎片化如果老年代内存不足会退化成单线程SerialOld回收。CMS适用于请求数据量大,频率高的场景,例如:订单接口、商品接口等。
Parallel Scavenge垃圾回收器:是JDK8默认的年轻代垃圾回收器,多线程并行回收,关注的是系统吞吐量,具备自动调节堆内存大小的特点。吞吐量高,但是不能保证单次的停顿时间,适用于后台任务,比如:大数据的处理、大文件的导出等。
Parallel Old垃圾回收器:是为Parallel Scavenge设计的老年代的垃圾回收器,利用多线程并发收集,一般来说,使用Parallel Old+Parallel Scavenge组合,不需要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整堆内存的大小。
当发生STW时,JVM会暂停所有非垃圾收集线程(即除GC线程之外的所有用户线程),STW事件会导致应用程序暂时失去响应,对于实时性要求高的应用来说,STW时间过长可能会对系统性能造成显著影响。
G1垃圾回收器:是年轻代和老年代的垃圾回收器,JDK9开始之后默认的垃圾回收器,G1垃圾回收器是结合了CMS垃圾回收器与Parallel Scavenge垃圾回收器的优点,具备以下三个优点:1.支持大的堆空间的回收,并有较高的吞吐量。2.支持多CPU并行垃圾回收。3.允许用户设置最大暂停时间。
G1的整个堆会被划分成多个大小相等的区域,区域不要求连续,具体分为:伊甸园区、幸存者区、老年代区。G1的垃圾回收有两种方式:年轻代回收(young GC),回收伊甸园区和幸存者区不用的对象,年轻代不足,会触发young GC,使用标记复制法进行垃圾清理。
当某个对象被复制多次,存活年龄达到阈值15,该对象将会被移入老年代,多次回收后,会出现很多的老年代区,此时总堆的占有率达到阈值,此时会采用标记复制法回收年轻代与老年代的对象。G1老年代的清理会选择存活度最低的区域进行回收,可以保证回收效率最高。如果发现没有足够的区域存放转移的对象,会触发full GC,单线程执行标记整理算法,会导致用户线程的暂停。
3.Java中四种引用方式?
强引用:通过可达性分析判断,对象被引用则不是垃圾,若对象到GCRoot不可达,可以认为是垃圾,会被回收掉。当内存不足的时候,只有对象被引用,就不会被回收。
软引用:当程序内存不足的时候,软引用对象会被回收掉。SoftReference类来实现软引用。软引用对象回收后,内存仍然不足可能会报OOM。
弱引用:使用弱引用的对象,再使用完成后无论是否存在引用都会被回收掉。弱引用使用WeakReference类来实现,弱引用主要在ThreadLocal中使用。
虚引用:虚引用的作用是对象被垃圾回收器回收的时候可以收到相应的通知。