写点什么

聊聊 Java 虚拟机(JVM)——基础篇

用户头像
Jerry Tse
关注
发布于: 2020 年 08 月 05 日

0. 前言

作为Java程序员,Java虚拟机(JVM)对我们来说既熟悉又陌生的。熟悉是我们的Java程序运行在虚拟机上,你也多多少少了解过虚拟机的一些特性,例如:“编译一次,到处运行”、“自动GC”等。但是对这些原理所设计的概念又一知半解,没有做到精确掌握。接下来我将从程序员角度介绍一些JVM概念及相应的知识。



1. 为什么有虚拟机?

作为一个Java程序员,你有没有问过自己,为什么会有虚拟机这个概念?为什么我们的Java代码是预编译成字节码通过虚拟机才能执行而不是编译为本地代码直接执行?虚拟机起什么作用呢?要回答这些问题我们就要介绍一下Java口号“编译一次,到处执行”。——Java代码跨平台运行能力



1.1 跨平台

这里说的跨平台,两层含义:

  • 跨硬件(CPU)平台

我们知道程序想要执行需要编译成机器码在CPU运行,不同的CPU有不同的指令集,提供不同的功能,所以针对某种类型CPU开发的程序很可能无法运行在另一个CPU环境中。

  • 跨软件(操作系统)平台

操作系统为我们提供了基础的接口封装,日常我们开发的软件大多数都是运行在某个操作系统上,所以我们程序都依赖操作系统的接口,例如创建线程,读取文件,发起网络请求等。但显然这些接口在不同的操作系统上都是不同的,如果没有全平台,这些也需要我们的语言去适配。



以上两点为应用程序运行它平台间的耦合点,了解软件设计的同学应该已将想到了,解耦通常的方式就是在耦合方和被耦合方之间建立一个虚拟层,虚拟机就是这个虚拟层。



有了虚拟机之后,Java程序不是直接编译为机器语言,而是预编译成字节码文件,字节码作为虚拟机输入交由虚拟机解释执行。这样,Java程序无需关系各个执行平台之间的差异,使用Java语言编写好的程序可以在各个平台所在的虚拟机上运行。做到“编译一次,到处执行”。

这里需要注意一点,我们这里所说的跨平台能力是针对Java编程语言,而不是针对Java虚拟机,不同的平台有不同的JVM。



1.2 托管环境

有了虚拟机之后我们就有了一个托管环境(Managed Runtime)。这个托管环境类似于代理模式,能够代替我们处理一些代码中冗长而且容易出错的部分。其中最广为人知的当属自动内存管理与垃圾回收。除此之外,托管环境还提供了诸如数组越界、动态类型、安全权限等等的动态检测,使我们免于书写这些无关业务逻辑的代码。这些能力都是在代码运行期间由Java虚拟机提供。



1.3 字节码执行方式

Java预编译后的自己码无法CPU上直接执行,需要JVM翻译为机器码后才能执行。有两种翻译执行的方式:

  • 解释执行:将字节码逐条翻译为机器码后执行。

  • 即时编译(JIT):即将一个方法中包含的所有字节码编译成机器码后再执行。



前者的优点是无需等待编译(程序启动的更快),后者的优势是运行速度更快。前者更适合客户端小程序,启动迅速,后者更适合于服务器端程序,长期执行效率更高。



HotSpot 默认采用混合模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。循环境内的执行的代码会被认定为热点代码,所以我们会观察到如下现象:如果我们执行一段循环方法,程序刚开始执行的时间通常多于后续执行的时间。



虚拟机启动的时候有相应参数,调整虚拟机启动时候的编译倾向,适配Client或server环境。



2. 虚拟机的内存分布

虚拟机既是一个软件、一个进程运行于操作系统中,也是一个代码执行Java代码的执行环境,所以它也有类似于操作系统的内存分布方式。

  • Java代码首先通过预编译将程序便以为class文件

  • class文件通过类加载器加载进JVM方法区

  • 静态变量

  • 常量池

  • 类中方法代码

  • 类的实例放入堆中

  • 类中的方法执行的本地变量放入栈中,通过栈帧的入栈出栈实现方法调用时变量可见性。

  • 本地方法变量放入本地方法栈

  • Java方法变量放入Java方法栈

  • 程序计数器记录每个线程在方法区执行方法的位置,方便线程切换的时候保留现场。



以上就是为JVM内存分布及各个区域内存主要存储的数据。



  • 方法区和堆线程共享,所以类中的实例变量、静态变量均线程不安全。

  • 栈和程序计数器为线程独享,所以方法内本地变量没有线程安全问题。

  • 判断一段代码时候有线程安全问题就是看是否有多线程场景下对方法区和堆存储数据的修改。

  • 堆中空间存放类的实例,当创建实例过多或者内存泄露(未引用实例无法释放),虚拟机会抛出OutOfMemory异常。

  • 栈资源是新开线程主要耗费的资源,Linux系统下新线程栈大小1M左右。所以一台计算机支持新开线程数有限。

  • 方法调用层次过深(无退出递归调用),同样会耗尽栈资源,虚拟机会抛出StackOverflow异常。

  • 程序计数器不会被耗尽资源。

3. 类加载

3.1 概念

在虚拟机内存分布我们聊到了类加载,类加载就是从磁盘中(也可以是网络、数据库等任何存储介质) class 文件变为到内存中的类过程。整个过程分为三步:

  • 加载(数组类虚拟机直接生成,没有此步骤)

  • 链接

  • 初始化



3.2 加载过程



3.2.1 加载器

指查找字节流,并且据此创建类的过程,这个过程由类加载器负责。在虚拟机中有三类加载器:

  • 启动类加载器(bootstrap class loader)

  • C++实现,没有对应的Java对象

  • 负载加载JRE 的 lib 目录下 jar 包(以及由虚拟机参数 -Xbootclasspath 指定的类)

  • 扩展类加载器(extension class loader)

  • 父类加载器是启动类加载器

  • 由 Java 核心类库提供,继承自java.lang.ClassLoader

  • 负责加载相对次要、但又通用的类,例如存放在JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)

  • 应用类加载器(application class loader)

  • 父类加载器则是扩展类加载器

  • 由 Java 核心类库提供,继承自java.lang.ClassLoader

  • 负责加载应用程序路径下的类(虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径)



3.2.2 双亲委派机制

每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。



作用:

  • 避免类被重复加载

  • 避免Java核心基础类被用户自定义类代替篡改



3.2.3 自定义加载器

通过集成ClassLoader方法可以实现自己的加载器。

作用:

  • 加载不同的数据源中class文件(通过网络,数据库)

  • 对class文件进行加密解密

  • 使用同一类的不同版本

  • 类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。



3.3 链接过程

是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。它可分为三个阶段

  • 验证

  • 准备

  • 解析



3.3.1 验证阶段

证阶段的目的,在于确保被加载类能够满足 Java 虚拟机的约束条件,我们不能不符合规范的字节码文件加载进虚拟机中。

3.3.2 准备阶段

准备阶段的目的,则是为被加载类的静态字段分配内存。Java 代码中对静态字段的具体初始化,则会在稍后的初始化阶段中进行。

3.3.3 解析阶段

解析阶段的目的,正是将类中的符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)



3.4 初始化过程

在 Java 代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。

类加载的最后一步是初始化,是为类中的静态变量和常量值的字段赋值,并且执行静态代码中的代码。

类的初始化过程仅执行一次,且线程安全。使用静态内部类实现的单例模式就是使用类初始化线程安全的特性来实现的多线程安全单例对象。

注意这个过程是类的初始化不是对象的初始化。



4. 创建对象

在Java程序中,我们拥有多种新建对象的方式:

  • new语句(最常见)

  • 反射机制

  • Object.clone方法

  • 反序列化

前两个通过调用构造函数来初始化实例字段,后两个通过直接复制已有的数据,来初始化新建对象的实例字段。

新创建的对象数据将被存储于堆中。



5. 垃圾回收

5.1 什么是垃圾回收

上面我们说过,虚拟机的一大优势就是可以实现自动内存管理和垃圾回收。虚拟机加载类之后可以通过多种方法创建他的实例,这些实例存储在堆空间上,所以虚拟机需要对堆空间的实例内容进行管理并且将不用资源释放



5.2 如何标记可回收空间

确定了垃圾回收的位置,我们如何确定堆空间中哪些空间需要回收,或者说哪些对象已经死亡,可以将其占用空间回收了呢?



5.2.1 引用计数器法

这里先介绍一种古老的辨别方法,引用计数法:

他为每一个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦一个对象的引用计数器为0,子说明对象已经死亡了,占用资源就可以被回收了。

他的缺点比较明显:

  • 需要额外占用空间存储对象的引用计数值

  • 需要拦截运行中所有对象的引用赋值操作并更新应用计数器

  • 循环引用问题

循环引用问题:

Person boyfriend=new Person();
Person girlfriend=new Person();
boyfriend.girlfriend=girlfriend;
girlfriend.boyfriend=boyfriend;
boyfriend=null;
girlfriend=null;

我们用伪代码说明循环引用问题。假设我们使用引用计数器方式判断对象是否存活。显然boyfriend、girlfriend所对应的对象在执行这段代码之后他们的引用计数器值都是1而不是0,表示他们还活着。但是boyfriend、girlfriend的对象已经不再被任何变量引用了,后面的代码也无法使用他们了。



5.2.2 可达性分析算法

这个是Java虚拟机实际使用的标记算法。

这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集,然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中。最终,未被探索到的对象便是死亡的,可以被回收。

什么作为GC Roots呢?

  • Java方法栈中的局部变量

  • Java类中的静态变量





我们通过上图再来分析上面的伪代码,执行完6行之后两个对象不在被GC Roots引用(如图中置灰部分),可以被标记回收。



对象间可以相互引用,只要这一串对象能被GC Roots引用,那么他们就是存活的。否则无论对象之间如何引用他们也是死亡的。



5.2.3 Stop-the-world

标记处哪些对象存活并不是一件容易的事情,程序是“活”的,对象的引用关系可能随时变化,尤其在多线程环境下,业务线程可能会更新已经访问过的对象中的引用。所以如果垃圾回收的线程和业务线程同时运行,标记无法保证准确。所以在垃圾回收时虚拟机会暂停其他业务线程,直到垃圾回收完成,这个方式就是stop the world。

一旦垃圾回收线程开始垃圾回收,垃圾回收器会发出一个stop the world请求,当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。

安全点是一种稳定的执行状态,在这个执行状态下,Java 虚拟机的堆栈不会发生变化(所以在安全点中执行的线程如果不出安全点也是可以和垃圾回收线程“并行”执行的)。应用线程都进入安全点后垃圾回收器就可以执行可达性分析了并回收对象了。



5.2.4 垃圾回收方式

介绍过如何标记可回收对象的空间,下一步就是具体的回收工作,主要有三种回收方式:

  • 清除(sweep)

  • 压缩(compact)

  • 复制(copy)

清除





说是清除,其实没有清除的操作,只需要将需要清除的对象所占用的空间标记记录在一个空闲列表中,当需要新建对象的时候,从空闲列表中寻找内存,划分给新建对象。

缺点:
  • 内存碎片化,分配对象的时候可能总空闲空间可用,但是碎片空间无法容纳对象所需内存。

  • 分配效率低下。每次分配内存,都需要逐个访问内存空间列表的项,查找能够放入对象的空闲内存。



压缩





把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间

优缺点:

这种做法避免了内存碎片化,但是带来了压缩算法本身的性能开销



复制





把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。

优缺点:

同压缩算法一样,也没有内存碎片问题。但是内存空间利用率不高,只能使用一半内存。



压缩和复制异同点

  • 两种方式都没有内存碎片问题,这样就可以使用指针加法分配新对象内存。

  • 两种方式都需要移动内存数据。前者属于原地移动,后者属于异地移动,前者的算法复杂度和开销高于后者,后者应用空间换时间理念。原地内存移动为什么复杂?感兴趣的同学可以参考这篇文章:

https://www.cnblogs.com/kekec/archive/2011/07/22/2114107.html

5.2.5 分代回收方式

分代回收并不是一种新的回收方式,他是基于“大部分的 Java 对象只存活一小段时间,而存活下来的小部分 Java 对象则会存活很长一段时间”这样一个假设,堆空间划分为两代,分别叫做新生代和老年代新生代用来存储新建的对象当对象存活时间够长时,则将其移动到老年代





利用分而治之的思想,Java虚拟机针对于不同的区域,使用不同的垃圾回收方式,以充分利用各种方式的优点。



新生代

  • 新生代采用复制方式进行垃圾回收,因为假设大部分新生对象存活时间很短。

  • 新生代分为Eden和Survivors区,Survivors又均分为from和to区域

  • 新建对象放入Eden区(也有例外,例如大对象可以直接进老年代)

  • Eden区即将耗尽时进行对Eden区域进行垃圾回收(Minor GC),将存活对象复制进from区域

  • 继续在Eden新建对象,直至即将耗尽后对Eden和from区域进行垃圾回收,将存活对象复制进to区域

  • 为存活对象记录存活次数,超过15次(可配置)之后将对象移动进入老年代

TLAB(Thread Local Allocation Buffer)

大多数新建对象在新生代分配,在多线程情况下新建对象的分配内存过程会有线程安全问题。新建对象是一个很频繁和基础操作,虚拟机并没有采用加全局锁的方式来限制多线程并发分配内存,而是为每个线程都分配了一块空间。这样多线程间可以并行新建对象。



卡表

Minor GC 的好处是不用对整个堆进行垃圾回收。但是,它却有一个问题,那就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。如果这真这样做就又变成了全堆扫描。

虚拟机给出的解决方案是一项叫做卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。



老年代

  • 老年代通常采用清除或压缩的方式进行垃圾回收。

  • 当老年代内存区域即将耗尽时,虚拟机会对整个堆空间进行垃圾回收,称之为Full GC



小结:

  • 新生代空间相较于老年代较小,所以Minor GC执行时间小于Full GC。

  • 无论哪种垃圾回收器,在标记可清理对象的时候都会有stop the world时间,只是长短不同而已。



6. 后语

本文我们以虚拟机存在的意义和作用开篇,分别介绍了虚拟机内存分布,如何加载类,如何创建对象,如何垃圾回收。这些都是虚拟机基础知识,也是我们正确理解Java语言和编写出高效Java代码的基础。下篇文章将从实战的角度出发,在理解虚拟机原理的基础上介绍如何优化虚拟机性能,如何编写出高效的Java代码。



发布于: 2020 年 08 月 05 日阅读数: 61
用户头像

Jerry Tse

关注

还未添加个人签名 2018.11.02 加入

还未添加个人简介

评论

发布
暂无评论
聊聊Java虚拟机(JVM)——基础篇