JVM(Java Virtual Machine)是一个虚拟出来的机器,是运行所有Java程序的抽象计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。
JVM 中主要有三个子系统
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载,验证,准备,解析,初始化,使用,卸载这7个阶段,其中其中验证、准备、解析3个部分统称为连接。JVM没有规定类加载的时机,但却严格规定了五种情况下必须立即对类进行初始化,加载自然要在此之前。
在加载的过程中,虚拟机会完成以下三件事情:
这一步的目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。具体验证的东西如下:
解析阶段是虚拟机将常量池内的符号引用(类、变量、方法等的描述符 [名称])替换为直接引用(直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄 [地址])的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
初始化阶段编译器会将类文件声明的静态赋值变量和静态代码块合并生成<clinit>方法并进行调用。
JVM层面支持两种类加载器:启动类加载器和自定义类加载器,启动类加载器由C++编写,属于虚拟机自身的一部分;继承自java.lang.ClassLoader的类加载器都属于自定义类加载器,由Java编写。逻辑上我们可以根据各加载器的不同功能继续划分为:扩展类加载器、应用程序类加载器和自定义类加载器。
在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。自定义类加载器作用:
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成Class对象,当触发类加载时,JVM并不知道当前类具体由哪个加载器加载,都是先给到默认类加载器(应用程序类加载器),默认类加载器怎么分配到具体的加载器呢,这边使用了一种叫双亲委派模型的加载机制。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。举例如下:
当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效。
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
Java虚拟机在执行Java程序时,会把它管理的内存划分为若干不同的数据区域。这区域各有各的用途以及生命周期。
方法区,也称非堆(Non-Heap),是一个被线程共享的内存区域。其中主要存储类的类型信息,方法信息,域信息,JIT代码缓存,运行时常量池等。
(1)方法区是各个线程共享的内存区域,在虚拟机启动时创建
(2)虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却又一个别名叫做Non-Heap(非堆),目的是与Java堆区分开来
(3)用于存储已被虚拟机加载的类信息、常量、即时编译器编译后的代码等数据。
(4)当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常
在JDK7之前,习惯把方法区称为永久代,而在JDK8之后,又取消了永久代,改用元空间代替。元空间的本质与方法区类似,都是对JVM规范中方法区这一内存区域的一种实现。不过元空间与永久代的最大区别就是:元空间不在虚拟机设置的内存中,而是直接使用的本地内存。所以元空间的大小并不受虚拟机本身的内存限制,而是受制于计算机的直接内存。
版本 | 区别 |
---|---|
JDK6及以前 | 有永久代,字符串常量池(String Table),静态变量都存放在永久代上 |
JDK7 | 有永久代,但字符串常量池,静态变量被从永久代中移出,放到了堆中 |
JDK8及以后 | 无永久代,改用元空间代替,但字符串常量池,静态变量依然放在堆中 |
注:永久代大小启动的时候指定,启动后不能更改;元空间大小如果不指定,最大就是物理内存。
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
一个有效的字节码文件除了包含各个类的版本信息、字段、方法以及接口等描述信息外,还包含了一项信息就是常量池表(Constant Pool Table),里面包含有各种字面量和对类型、域和方法的符号引用。
字面量和符号引用:举个简单例子,String str = new String(“你好”);其中"你好"就是字面量,而str则为符号引用。str符号引用需要在类加载的解析阶段转换为直接引用,因为 str的值是可以变化的,我们不能在第一时间确定其真正的值,需要在动态运行中进行解析。符号引用包含以下三类:
一个字节码文件被加载到虚拟机后,字节码文件中的一些数据,如之前提到过的类型信息,域信息,方法信息等,就会被放置到方法区中,而字节码文件中的常量池则会进入方法区中的运行时常量池(注:字符串常量/字面量会放到堆中),JVM为每个已加载的类或者接口都维护一个常量池,即运行时常量池是每个类都有一个。常量池中数据像数组一样,是通过索引访问的。
Java并不要求常量必须在编译时才能产生,即并不是class文件常量池中的内容才能进入运行时常量池,在运行期间也同样可以将新的常量放入池中。比如,可以使用String.intern()动态生成字符串常量,String.intern()方法的作用就是当池中没有相应的字符串常量时,在运行时动态生成。
Java堆是Java虚拟机所管理的内存最大的一块区域,Java堆是线程共享的,在虚拟机启动时创建。
年轻代和老年代的划分是为了更好的内存分派及回收,提高效率。
(1)对象优先在Eden 区分配
(2)大对象直接进入老年代
上面提到,新创建的对象是优先存放入 Eden 区的,但是新创建的大对象会直接进入老年代。
什么是大对象: 10M 的对象算大吗?100M 的对象呢?什么是大对象?大对象的标准是可以由开发者定义的,我们的 JVM 参数中,能够通过 -XX:PretenureSizeThreshold 这个参数设置大对象的标准,可惜的是这个参数只对 Serial 和 ParNew 两款新生代收集器有效。
对于不能够设置 -XX:PretenureSizeThreshold 参数的JVM来说, Eden 区容量不够存放的对象就是所谓的大对象。
新生成的非大对象首先放到年轻代 Eden 区,当 Eden 空间满了,触发 Minor GC,存活下来的对象移动到Survivor0 区,Survivor0 区满后触发执行Minor GC,Survivor0 区存活对象移动到 Suvivor1 区,这样保证了一段时间内总有一个Survivor 区为空。经过多次Minor GC 仍然存活的对象移动到老年代。
如果新生成的是大对象,会直接将该对象存放入老年代。
老年代存储长期存活的对象,GC 期间会停止所有线程等待 GC 完成,所以对响应要求高的应用尽量减少发生 Major GC,避免响应超时。
作用:JVM 通过判断对象的具体年龄来判别是否该对象应存入老年代,JVM通过对年龄的判断来完成对象从年轻代到老年代的转移。
对象年龄(Age)计数器:HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。
年龄增加:对象通常在 Eden 区里诞生,如果经过第一次 Minor GC 后仍然存活,并且能被Survivor容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为 1 岁。对象在Survivor区中每熬过一次 Minor GC,年龄就增加 1 岁。
年龄默认阈值:当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。
触发Full GC执行的情况有如下五种:
Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用,是线程私有的,因此也是线程安全的。
定义:栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。它是虚拟机运行时数据区中的 Java 虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。
栈帧初始化大小:在编译程序代码的时候,栈帧中需要多大的局部变量表内存,多深的操作数栈都已经完全确定了。 因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
栈帧结构:在一个线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
(1)局部变量表
在栈帧中,局部变量表占用了大部分的空间,那么接下来我们看下局部变量表的基本概念与特点。
基本概念:每个栈帧中都包含一组称为局部变量表的变量列表,用于存放方法参数和方法内部定义的局部变量。
特点:
a.局部变量表的容量以变量槽(Variable Slot)为最小单位;
b.局部变量表中的 Slot 是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码程序计数器的值已经超过了某个变量的作用域,那么这个变量相应的 Slot 就可以交给其他变量去使用,节省栈空间。
(2)操作数栈
方法的执行操作在操作数栈中完成,每一个字节码指令往操作数栈进行写入和提取的过程,就是入栈和出栈的过程;
操作数栈的每一个元素可以是任意的 Java 数据类型,32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2;
简单的理解,操作数栈存放的是当前正在操作的变量,可以是局部变量或者对象实例字段中的常量和变量。比如执行到代码a=b+c时就会把a,b,c都存入到操作数栈来。
(3)动态链接
动态链接保存的是一个引用或者说指针,它指向该栈帧所属方法在运行时常量池(方法区)中的地址,它支持着Java的多态特性。
在 class 文件的常量池(存储字面量和符号引用)中存有大量的符号引用(1. 类的全限定名,2. 字段名和属性,3. 方法名和属性),字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。
这些符号引用一部分会在类加载过程的解析阶段转化为直接引用(指向目标的指针、相对偏移量或者是一个能够直接定位到目标的句柄),称为静态解析。另外一部分将在运行期期间转化为直接引用,称为动态链接(父类方法链接到子类,会引用子类重写的方法)。
(4)返回地址
方法返回地址是方法在PC寄存器中的值,也即是该方法的指令地址,方便执行引擎在执行完该方法后,回到该方法对应的指令行号,这样才能继续执行下去。
返回地址代表的是方法执行结束,当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令(例如:return),这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 throw 字节码指令产生的异常,只要在本方法的异常处理器表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整程序计数器的值以指向方法调用指令后面的一条指令等。
程序计数器的英文全称是Program Counter Register,又叫程序计数寄存器。Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。JVM中的PC寄存器是对 物理PC寄存器的一种抽象模拟。
本地方法栈与虚拟机栈所发挥的作用是非常相似的。
只不过虚拟机栈为虚拟机执行的Java方法(即字节码)服务,本地方法栈为虚拟机执行的本地方法(Native方法、C/C++ 实现)服务。
与虚拟机栈一样,当栈深度溢出时,抛出 StackOverFlowError 异常。
当栈扩展内存不足时,抛出 OutOfMemoryError 异常。
JVM的主要任务之一是负责装载字节码到其内部(运行时数据区),但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只 是一些能够被 JVM 所识别的字节码指令、符号表,以及其他辅助信息。
那么,如果想要让一个 Java 程序运行起来,执行引擎(Execution Engine) 的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。
当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令然后执行。
JVM 设计者们的初衷仅仅只是单纯地为了满足 Java 程序实现跨平台特性,因此避免采用静态编译的方式由高级语言直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。
解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行,执行效率低。
在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低效。而模板解释器将每一条字节码和一个模板函数性关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
在Hotspot VM中,解释器主要由Interpreter模块和Code模块构成。
由于解释器在设计和实现上非常简单,因此除了 Java 语言之外,还有许多高级语言同样也是基于解释器执行的,比如:Python、Perl、Ruby等。但就是因为多了中间这一“翻译”过程,导致代码执行效率低下。
为了解决这个问题,JVM平台支持一种叫做即时编译的的技术。即时编译的目的是为了避免函数被解释执行,而是将整个函数编译成机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
就是虚拟机将Java字节码一次性整体编译成和本地机器平台相关的机器语言,但并不是马上执行。JIT 编译器将字节码翻译成本地机器指令后,就可以做一个缓存操作,存储在方法区 的 JIT 代码缓存中。JVM真正执行程序时将直接从缓存中获取本地指令去执行,省去了解释器的工作,提高了执行效率高。
HotSpot VM 是目前市面上高性能虚拟机的代表作之一。它采用解释器与及时编辑器并行的结构。在 Java 虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
JIT 编译器执行效率高为什么还需要解释器?
是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用的执行频率而定。关于那些需要被编译成本地代码的字节码,也被称为热点代码,JIT编译器在运行时会对那些频繁被调用的热点代码做出深度优化,将其直接编译成对应平台的本地机器指令,以此提升Java程序的执行性能。
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体,都可以被称为热点代码。因此都可以通过JIT编译器编译成本地机器指令。由于这种编译方式发生在方法执行的过程中,因此也被称为栈上替换,或者简称为OSR编译。
一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准,必然需要一个明确的阈值。JIT编译器才会将这些热点代码编译成本地机器码执行。
简单的说垃圾就是内存中不再使用的对象,所谓使用中的对象(已引用对象),指的是程序中有指针指向的对象;而不再使用的对象(未引用对象),则没有被任何指针指向。如果这些不再使用的对象不被清除掉,我们内存里面的对象会越来越多,而可使用的内存空间会越来越少,最后导致无空间可用。
垃圾回收的基本步骤分两步:
即内存中不再使用的对象,判断对象存活一般有两种方式:引用计数算法和可达性分析法
(1)引用计数算法
给对象添加一个引用计数器,每当有一个地方引用该对象时,计数器+1,当引用失效时,计数器-1,任何时候当计数器为0的时候,该对象不再被引用。
优点:引用计数器这个方法实现简单,判定效率也高,回收没有延迟性。
缺点:无法检测出循环引用。 如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0,Java的垃圾收集器没有使用这类算法。
(2)可达性分析算法
可达性分析算法是目前主流的虚拟机都采用的算法,程序把所有的引用关系看作一张图,从所有的GC Roots节点开始,寻找该节点所引用的节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。
在Java语言中,可作为GC Roots的对象包括下面几种:
小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
垃圾(内存中不再使用的对象)回收对象之前,会调用该对象的 finalize()方法(主要在对象被回收时进行资源释放,通常是一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等)
(1)标记-清除算法
标记-清除算法的基本思想就跟它的名字一样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象
标记阶段
标记的过程其实就是前面介绍的可达性分析算法的过程,遍历所有的 GC Roots 对象,对从 GCRoots 对象可达的对象都打上一个标识,一般是在对象的 header 中,将其记录为可达对象;
清除阶段
清除的过程是对堆内存进行遍历,如果发现某个对象没有被标记为可达对象(通过读取对象header 信息),则将其回收。
标记-清除算法缺点
效率问题
标记和清除两个阶段的效率都不高,因为这两个阶段都需要遍历内存中的对象,很多时候内存中的对象实例数量是非常庞大的,这无疑很耗费时间,而且 GC 时需要停止应用程序,这会导致非常差的用户体验。
空间问题
标记清除之后会产生大量不连续的内存碎片(从上图可以看出),内存空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。
(2)复制算法
复制算法是将可用内存按容量划分为大小相等的两块,每次使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块内存上,然后把这一块内存所有的对象一次性清理掉。
复制算法每次都是对整个半区进行内存回收,这样就减少了标记对象遍历的时间,在清除使用区域对象时,不用进行遍历,直接清空整个区域内存,而且在将存活对象复制到保留区域时也是按地址顺序存储的,这样就解决了内存碎片的问题,在分配对象内存时不用考虑内存碎片等复杂问题,只需要按顺序分配内存即可。
复制算法优点
复制算法缺点
(3)标记-整理算法
标记-整理算法算法与标记-清除算法很像,事实上,标记-整理算法的标记过程任然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行回收,而是让所有存活的对象都向一端移动,然后直接清理掉端边线以外的内存。
可以看到,回收后可回收对象被清理掉了,存活的对象按规则排列存放在内存中。这样一来,当我们给新对象分配内存时,JVM只需要持有内存的起始地址即可。标记/整理算法弥补了标记/清除算法存在内存碎片的问题消除了复制算法内存减半的高额代价,可谓一举两得。
标记-整理缺点
(4)分代收集算法
前文介绍JVM堆内存时已经说过了分代概念和对象在分代中的转移,垃圾回收伴随了对象的转移,其中新生代的回收算法以复制算法为主,老年代的回收算法以标记-清除以及标记-整理为主。
方法区主要回收的内容有:废弃常量和无用的类。Full GC(Major GC)的时候会触发方法区的垃圾回收。
从Java1.1开始,JNI标准成为Java平台的一部分,它允许(或者说使得)Java代码和其他语言写的代码进行交互。JNI一开始是为了本地已编译语言,尤其是C和C++而设计的,但是它并不妨碍你使用其他编程语言,只要调用约定受支持就可以了。使用Java与本地已编译的代码交互,通常会丧失平台可移植性。但是,有些情况下这样做是可以接受的,甚至是必须的。例如,使用一些旧的库,与硬件、操作系统进行交互,或者为了提高程序的性能。JNI标准至少要保证本地代码能工作在任何Java 虚拟机环境。