PS:本文以下部分,默认都是使用HotSpot,也就是Oracle Java 默认的虚拟机为前提来进行介绍的。
1.JVM执行流程
程序在执行之前先要把Java代码转换成字节码(.class文件),JVM首先需要把字节码通过一定的方式类加载器(ClassLoader)把文件加载到内存中 运行时数据区(Runtime Data Area),而字节码文件是JVM的一套指令集规范,并不能直接交割底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine)将字节码翻译成底层系统指令再由CPU去执行,而这个过程中需要调用其他语言本地库接口(Native Interface)来实现整个程序的功能,这就是这4个主要组成部分的职责与功能
总结来看,JVM主要通过分为以下4个部分,来执行Java程序的,它们分别是:
- 类加载器
- 运行时数据区
- 执行引擎
- 本地库接口
2.JVM运行时数据区
JVM运行数据区也叫做内存布局,但需要注意的是他和Java内存模型完全不同,属于完全两个不同的概念,它由以下5大部分组成:
2.1堆(线程共享)
堆的作用:程序中所有对象都保存在堆中
堆里面分为两块区域:新生代和老年代,新生代放新建的对象,当经过一定GC次数后还存活的对象会放入老年代。新生代还有3个区域:一个Endn + 两个 Survivor (S0/S1)
新创建的对象会放到Eden中,经过少数几个回合,会进入S0或者S1(这两个部分只一次使用一个),在经过较长的几个回合,会进入Old区。同时系统判定这几个区域中的对象是否垃圾的频率也会逐渐减少的
垃圾回收的时候会将Endn中存活的对象放到一个未使用的Survivor中,并把当前的Endn和正在使用Survivor清除掉
2.2Java虚拟机栈(线程私有)
Java虚拟机栈的作用:Java虚拟机栈的声名周期和线程相同,Java虚拟机栈的描述是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧,用于储存局部变量表、操作数栈、动态链接、方法出入口等信息。咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈
Java虚拟机栈主要包含了以下4个部分
- 局部变量表:存放了编译器可知的各种基本数据类型、对象引用。局部变量表所需的内存空间在编译时期完成分配,当进入一个方法时,这个方法需要在栈中分配多大的局部变量是完全确定的,在执行期间不会改变局部变量表大小。简单来说就是存放方法参数和局部变量。
- 操作栈:每个方法都会生成一个先进后出的操作栈
- 动态链接:只想运行时常量池的方法引用
- 方法返回地址:PC寄存器的地址
什么是线程私有?
由于JVM的多线程是通过线程轮盘切换分配处理器执行时间的方式来实现的,因此在任何一个确定的时刻,一个内核都只会执行一个线程中的指令。因此为了切换线程后能够恢复到正确的执行位置,每个线程都有独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们就把类似这类区域称为“线程私有”的内存。
2.3本地方法栈(线程私有)
本地方法栈和虚拟机栈类似,只不过Java虚拟机栈是给JVM使用的,而本地方法栈是给本地方法使用的
2.4程序计数器(线程私有的)
程序计数器的作用:用来记录当前线程执行到的行号
程序计数器是一块比较小的内存空间,可以看作线程所执行的字节码的行号指示器。
如果当前线程正在执行一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正常执行的是一个Nativa方法,这个计数器值为空
程序计数器内存区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域
2.5方法区(线程共享)
方法区的作用:用来存储被虚拟机加载的类信息、常量、静态变量,即使编译器编译后的代码等数据的
方法区也被叫做“元空间”
2.5.1JDK1.8元空间的变化
- 对于HotSpot来说,JDK 8元空间的内存属于本地内存,这样元空间的大小就不在受JVM最大内存的参数影响了,而是与本地内存大大小有关了
- JDK 8中元字符串常量池移动到了堆中
2.5.2运行时产量池
运行时常量池时方法区的一部分,存放字面量与符号引用
字面量:字符串(JDK 8 移动到堆中)、final常量、基本数据类型的值
符号引用:类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符
3.JVM类加载
3.1类加载过程
对于一个类来说,它的生命周期是这样的:
其中前5步是固定的顺序并且也是类加载的过程,其中中间的3步属于连接,所以对类加载来说总共分为以下几个步骤:
- 加载
- 验证
- 准备
- 解析
- 初始化
3.2加载
“加载”阶段是整个“类加载”过程中的一个阶段,它和类加载Class Loading是不同的,一个是加载Loading,另外一个类加载Class Loading,所以不要把二者混淆了
在加载阶段,Java虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态储存结构转化为方法区运行时的数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
3.3验证
验证是连接阶段的第一步,这个阶段目的是确保Class文件的字节流包含的信息符合《Java虚拟机规范》的全部约束要求,确保这些信息被当作代码运行后不会危害虚拟机自身的安全
【验证选项】
- 文件格式验证
- 字节码验证
- 符号引用验证...
3.4准备
准备阶段是正为类中定义的变量(即静态变量)分配内存并设置类变量初始化值的阶段。
比如此时有这样的代码
public static int value = 123;
它是初始化value的int值为0,而非123
3.5解析阶段
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程
符号引用:一组字符来表示描述引用的目标,只要能定位到目标即可
直接引用:需要这个对象的时候,我们直接填写这个对象真实的物理地址来定位目标
在Java中,一个Java类将会编译成一个class文件。在编译的时候,并不知道一个类的真实地址,只能用符号引用代替。比如在编译阶段,Student类需要一个Teacher类,但是Student并不知道Teacher类的真实地址,只能用一段特殊且唯一的字符串表示,通过这个字符串能找到Teacher类
3.6初始化
初始化阶段,Java虚拟机整整开始执行类中编写的Java程序代码,将主导权交给应用程序。初始化阶段就是执行类构造器方法的过程。
作用:初始化静态成员变量,执行静态代码块,类要有父类还需要加载父类...
站在Java虚拟机的角度看,只存在两种不同的类加载器:一种是启动类加载器,这个类加载器使用C++语言实现,是Java虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都是由Java语言来实现的,独立存在与虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader
站在Java开发人员的角度看,类加载器应当划分的更加细致一些。自从JDK1.2依赖,Java一直保持着三层类加载器、双亲委派的类加载器。
什么是双亲委派模型?
如果一个类加载器收到了类加载的请求,它首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,每个层次的类加载器都是如此,因此所有的加载请求最终都应传送到最顶层的启动类加载器中,自由当父类加载器反馈自己无法完成这个加载请求(它的所有范围中没有找到所需要的类)时,子加载器才会尝试自己去完成加载。
在JVM中,内置了三个类加载器
- BootStrap ClassLoader 负责加载Java标准库中的类
- Extension ClassLoader 负责加载一些非标准但是Sun/Oracle扩展的库的类
- Application ClassLoader 负责加载在项目中自己写的类以及第三方库中的类
它们三者,有着继承关系,BootStrap ClassLoader为Extension ClassLoader的父类,Extension ClassLoader 为Application ClassLoader的父类
当加载自己写的类时,会调用Application ClassLoader类加载器,但是它不会直接去搜索,而是交给它的父类,Extension ClassLoader也不会搜索,而是交给BootStrap ClassLoader,它会搜索自己管理的范围,没有找到它会交给自己的子类Extension ClassLoader,让它去搜索相应的范围,找不到,就会返回给Application ClassLoader,让它搜索。(双亲委派模型时可以打破的)
4.垃圾回收相关
主要讲内存分配和回收关注的Java堆和方法区这两个区域。在堆中放着各种各样的对象,但是有些已经不使用了,我们称它们为“死亡对象”,我们要对死亡对象进行回收。
这就牵扯出了连个问题:怎么判定死亡对象?如何回收死亡对象?
4.1死亡对象判定的算法
4.1.1引用计数算法
引用计数算法为:给对象增加一个引用计数器,每当有一个地方引用它时,这个计数器就会加1;当这个计数失效时,就会-1;在这个计数器为0时,我们就能销毁这个对象。
引用计数法实现简单,判定小路高,在大多数情况下是一个不错的选择,如Python语言就采取这种垃圾回收机制。(注意Java不采用此算法)
但是引用计数法无法解决循环引用的问题。
比如:a的对象的地址是0x11, b对象的地址是0x22,我们让堆中0x11的对象的内容指向了0x22,又让0x22的对象的内容指向了0x11,这样每个对象的程序计数器都为2
此时这个ab都被销毁了,0x11和0x22对象程序计数器都-1,变为1。我们发现我们使用不了这两个对象,但是却也不能被销毁,这就是循环引用的问题
4.1.2可达性分析算法
可达性分析算法:通过一系列称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,当一个对象到GC Root没有任何引用链连相连的时候,证明这个对象不可用
如上图,从GC roots出发,object5、object6没有办法到达,所以它们两个可以被判定为可回收对象。
但是这种方法也有不好的方面:
比如我们使用这种方法搜索的时候,程序是暂停的(如果不暂停,就会导致新的对象不在这颗树上,导致错误回收,也有可能导致建立这颗树的时候这个对象在这颗树上,但是搜索过程中实际上不存在了,导致对象回收不到),这导致程序的运行效率比较低。
这个是Java所采用的方法,经过这麽多年的发展,垃圾回收也在不断的优化,已经尽量减少暂停的时间。
4.2垃圾回收算法
通过上面的算法,我们已经讲死亡对象标记出来了,标记出来以后,我们就可以对这些对象进行回收了。我们来了解下如何回收吧.
4.2.1标记-清除算法
我们刚刚已经判定了什么是死亡对象,接下来我们就直接进行清除了
标记—清除算法的问题:
- 效率不高:每次清除只是清除一小片区域,然后再跳转到下一个区域进行清除
- 空间问题:清除后,导致空间非常的零散,在申请大的对象的空间时候,虽然总的空间够用,但是由于空间不是连续的,但是无法正常的利用
4.2.2复制算法
复制算法:将空间分为两部分,每次使用只能使用其中的一块空间,在进行垃圾回收后,把对象复制到另一块空的空间内
缺点:我们发现这种算法浪费空间比较大
4.2.3标记-整理算法
标记-整理算法:就是在标记清除算法的基础上,清除过后,再进行整理,这样就能腾出大量连续空闲空间了
缺点:有些对象可能永久不会销毁,这导致了一些对象每次垃圾回收都会进行复制
4.2.4分代算法(重点)
当前JVM的垃圾回收采用的是分代算法,这个算法将内存划分了几个不同的区域,即新生代和老年代。新生代又分为一个较大的伊甸区和两块较小的幸存者区
- 新创建的对象都会进入伊甸区,触发第一次Minor gc时,大部分对象都会被销毁,仍然活着的对象进入正在使用的幸存区;
- 进入幸存区后经过Minor gc会将幸存的对象转移到另一块空着的幸存区,并清空此幸存区;在经过很多回合的Minor gc后,依然存活的对象会进入老年代;同时扫描判定是否为可回收对象的频率比伊甸区低
- 老年代可以采取标记-整理的垃圾回收策略,同时被扫描判定是否为可回收对象的频率很低
【回顾一个对象的一生】
一头小鹿(对象)出生在一块叫做伊甸区的地方,这个地方每年会发生一次自然灾难(Minor gc),大部分鹿都死了;第一年幸存的鹿进入到了一个叫做幸存区的地方,自然灾难不是每年发生,但是也是经常发生,在发生自然灾难后幸存的鹿会去到另一块幸存区;经过多年的灾难,同一时期产生的鹿可能只有一个了,进入到了一个叫做老年代的区域,这个区域自然灾难很少,它们就生活在这里