JVM
一、前言
JVM也就是Java Virtual Machine,即Java虚拟机。我们常用过的虚拟机比如VMware,属于系统虚拟机,完全对物理计算机的仿真,提供一个可运行完整操作系统的平台。而Java虚拟机则为程序虚拟机,专门设计为执行某些计算机程序而实现,在Java虚拟机内执行的是Java字节码指令,所有的Java程序都要在Java虚拟机内执行。
二、JVM介绍
1. 整体组成
JVM整体组成部分包括 类加载器 、运行时数据区、执行引擎 还有 本地方法库
程序运行之前我们通常会对程序进行编译,从.Java的文件编译成.Class字节码文件,运行程序所需Class文件时,Jvm会通过类加载器(ClassLoader)把文件对象创建至运行时数据区中,但是字节码文件是一整套针对Jvm的指令集规范,需要通过Jvm内的执行引擎把字节码翻译成底层系统执行来执行,而这个过程需要调用本地方法库接口来实现功能。
而通常我们在使用Jvm过程中,最主要用到和调试的部分就是运行时数据区。
2. 简述类加载器
① 加载流程
类加载子系统负责从文件系统或者网络中加载Class文件。ClassLoader只负责class文件的加载,至于它是否可以运行,则由执行引擎决定。加载后的类存在放在运行时数据区的方法区当中
② 分类
- 引导类加载器(启动类加载器 BootStrap ClassLoader)
这个类加载器使用 C/C++语言实现,嵌套在 JVM 内部。它用来加载 java 核心类库,并不继承于 java.lang.ClassLoader 没有父加载器,负责加载扩展类加载器和应用类加载器,并为他们指定父类加载器。
出于安全考虑,引用类加载器只加载存放在<JAVA_HOME>\lib 目录,或者被-Xbootclasspath 参数所指定的路径中存储放的类。
- 扩展类加载器(Extension ClassLoader)
该加载器由Java 语言编写的,继承于 sun.misc.Launcher$ExtClassLoader。
从 java.ext.dirs 系统属性所指定的目录中加载类库,或从 JDK 系统安装目录的jre/lib/ext 子目录(扩展目录)下加载类库.如果用户创建的 jar 放在此目录下,也会自动由扩展类加载器加载。
- 应用程序类加载器(系统类加载器 Application ClassLoader)
该加载器由Java 语言编写的,继承于 sun.misc.Launcher$ExtClassLoader。
加载我们自己定义的类,用于加载用户类路径(classpath)上所有的类,该类加载器是程序中默认的类加载器。
③ 双亲委派机制
Java 虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要该类时才会将它的 class 文件加载到内存中生成 class 对象,而且加载某个类的 class 文件时,Java 虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式
工作原理:
1.如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类加载器去执行.
2.如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终 将到达顶层的启动类加载器.
3.如果父类加载器可以完成类的加载任务,就成功返回,倘若父类加载器无法完成加载任务,子加载器才会尝试自己去加载,这就是双亲委派机制.
如果均加载失败,就会抛出 ClassNotFoundException 异常。
3. 运行时数据区
① 组成
JDK1.8之前:
JDK1.8:
a. 程序计数器
程序计数器是⼀块较⼩的内存空间,可以看作是当前线程所执⾏的字节码的⾏号指示器。字节码解释器⼯作时通过改变这个计数器的值来选取下⼀条需要执⾏的字节码指令,分⽀、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
每条线程都需要有⼀个独⽴的程序计数器,各线程之间计数器互不影响,独⽴存储,我们称这类内存区域为“线程私有”的内存。(程序计数器是唯⼀⼀个不会出现 OutOfMemoryError 的内存区域,它的⽣命周期随着线程的创建⽽创建,随着线程的结束⽽死亡。)
b. Java 虚拟机栈
Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每个方法从调用直至执行完成的过程,都对应着一个线帧在虚拟机栈中入栈到出栈的过程。遵循的原则就是先进后出、后进先出的原则,当栈内存不足以支撑栈帧大小时,程序会抛出StackOverFlowError 异常,比如递归未合适的结束。(例:1、2)
栈帧的内部结构:
- 局部变量表(Local Variables)
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。比如:编译器可知的各种数据类型(boolean、byte、char、short、int、float、 long、double)、对象引⽤(reference类型,它不同于对象本身,可能是⼀个指向对象起始地址的引⽤指针。
- 操作数栈(Operand Stack)(或表达式栈)
栈最典型的一个应用就是用来对表达式求值。在一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。
- 动态链接(Dynamic Linking) (或指向运行时常量池的方法引用)
因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。
- 方法返回地址(Retuen Address)(或方法正常退出或者异常退出的定义)
当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。
c. 本地方法栈
和虚拟机栈所发挥的作⽤⾮常相似,区别是: 虚拟机栈为虚拟机执⾏ Java ⽅法 (也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。 (在 HotSpot 虚拟机中和 Java 虚拟机栈合⼆为⼀。)
本地⽅法被执⾏的时候,在本地⽅法栈也会创建⼀个栈帧,⽤于存放该本地⽅法的局部变量表、操作数栈、动态链接、出口信息。 ⽅法执⾏完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。
d. Java 堆
一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域。Java 堆区在 JVM 启动时的时候即被创建,其空间大小也就确定了,是 JVM 管理的最大一块内存空间。(在Java启动脚本上,可以指定堆的内存大小, -Xms:10m(堆起始大小) -Xmx:30m(堆最大内存大小))。
所有的线程共享 Java 堆,在这里还可以划分线程私有的缓冲区。Java的对象实例创建时都是运行在堆内的,栈帧通过引用使用堆内的对象。(引用有分为强引⽤,软引⽤,弱引⽤,虚引⽤)。
在程序运行时,一个方法结束后,堆中的对象不会马上被移除,对象会在堆内存进行垃圾回收时移除,所以堆是 GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
堆内存组成:
新生区(新生代)+老年区(老年代)
新生代包含Eden区(伊甸区) + surviror0(S0,即幸存者0区) + surviror1 (S1,即幸存者1区)
老年代存放年龄达到MaxTenuringThreshold设置值(默认15)的对象。
e. 方法区
方法区,是一个被线程共享的内存区域。其中主要存储加载的类字节码、class/method/field 等元数据、static final 常量、static 变量、即时编译器编译后的代码等数据。另外,方法区包含了一个特殊的区域“运行时常量池”。
Java 虚拟机规范中明确说明:”尽管所有的方法区在逻辑上是属于堆的一部分,但对HotSpotJVM 而言,方法区还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开.
所以,方法区看做是一块独立于 java 堆的内存空间。
② 垃圾回收(GC)
(例:1,2,3)
a. 概述
Java 和 C++语言的区别,就在于垃圾收集技术和内存动态分配上,C++语言没有垃圾收集技术,需要程序员手动的收集,而Jvm提供了这一自动化的过程。这边的垃圾,指的就是运行程序中没有任何引用指向的对象,也就是没有再被使用到的对象,如果不对这些对象所占用的内存进行回收,时间长了以后,就会出现内存溢出,也就是OutOfMemoryError异常。
b. 类型
垃圾回收的类型分为两种 Minor GC (新⽣代GC)和 Major GC(老⽣代GC)。
Minor GC:指发⽣新⽣代的的垃圾收集动作,Minor GC⾮常频繁,回收速度⼀般也比较快。⼤多数情况下,对象在新⽣代中 eden 区分配。当 eden 区没有⾜够空间进⾏分配时,虚拟机将发起⼀ 次Minor GC。
Major GC:指发⽣在⽼年代的GC,出现了Major GC经常会伴随⾄少⼀次的 Minor GC(并⾮绝对,一般称为Full GC),Major GC的速度⼀般会⽐Minor GC的慢10倍以上。
c. 垃圾标记
再进行垃圾回收之前,堆内需要通过垃圾标记标记那些不再被使用也就是死亡的对象,从而进行后续内存释放动作。那么堆内是如何判断该对象是否已经死亡的呢?堆中判断对象已经死亡的方式有两种:引⽤计数法和可达性分析算法
- 引⽤计数法
该算法实现的原理就是个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。比如一个对象 A,只要有任何一个引用指向了对象 A,则对象 A 的引用计数器就加 1;当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,即表示对象 A 不可能再被使用,可进行回收。
这种算法判断效率高,但是需要有一块内存来计数并且在加减计数时,增加了时间开销。还有一个致命缺点就是当对象间循环引用时,该计数法无法判断是否需要回收该对象,从而导致内存泄露。(例 4)
- 可达性分析算法
这个算法的基本思想就是通过⼀系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所⾛过的路径称为引⽤链,当⼀个对象到 GC Roots 没有任何引⽤链相连的话,则证明此对象是不可⽤的。该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
d. 回收算法
在堆内存内可用内存不够时,会执行垃圾回收,将被标记为垃圾的对象进行回收。
- 标记-清除算法
该算法的原理就是将被标记为垃圾的对象内存清除掉,这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放(也就是覆盖原有的地址)。这种算法的缺点就是会导致清除完的内存不够连续。
- 标记-复制算法
为了解决清除算法效率问题,“复制”收集算法出现了。它可以将内存分为⼤⼩相同的两块,每次使⽤其中的⼀ 块。当这⼀块的内存使⽤完后,就将还存活的对象复制到另⼀块去,然后再把使⽤的空间⼀次清理掉。这样就使每次的内存回收都是对内存区间的⼀半进⾏回收。缺点是需要每次将内存分为两块,需要更大的内存空间。
- 标记-整理算法
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。 如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用标记-整理算法。
该算法根据是⽼年代的特点特出的⼀种算法,标记过程仍然与“标记-清除”算法⼀样,但后续步骤不是直接对可回收对象回收,⽽是让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存。
三、范例
1. 弹栈与引用
2. 递归
3. 卫语言
4. 内存回收及溢出