Android开发Android开发经验谈Android技术知识

关于Java虚拟机,你需要了解的

2020-05-07  本文已影响0人  zackyG
概述

我们常说的JDK(Java Development Kit)包含了Java语言、Java虚拟机和Java API类库这三部分,是Java程序开发的最小环境。而JRE(Java Runtime Environment)包含了Java API中的Java SE API子集和Java虚拟机这两部分,是Java程序运行的标准环境。可以看出Java虚拟机的重要性,它是整个Java平台的基石,是Java语言编译代码的运行平台。你可以把Java虚拟机看做一个抽象的计算机,它有各种指令集和运行时数据区域。
虽然叫Java虚拟机,但其实它能运行的语言不仅仅是Java,还包括Kotlin、Groovy等。同时需要注意的是,Android中的Dalvik和ART虚拟机并不属于Java虚拟机。

Java虚拟机的执行流程

当我们执行一个Java程序时,它的执行流程如下图所示:


Java程序执行流程

从图中可以发现,java程序的执行流程可以分为四个步骤:编辑源代码、编译生成Class文件、加载Class文件、执行Class文件里的字节码指令。当一个Java'文件经过Java编译器编译后会生成Class文件,这个Class文件会由Java虚拟机处理。Java虚拟机与Java语言并没有什么必然的联系,它只与特定的二进制文件:Class文件有关。因此无论任何语言,只要能编译成Class文件,就可以被Java虚拟机识别并执行。

Java虚拟机结构
Java虚拟机结构

Java虚拟机结构包含运行时数据区域、执行引擎、本地库接口和本地方法库。但上图中所示的类加载子系统并不属于Java虚拟机的内部结构。图中还标出了线程共享区域和线程私有的区域,比如方法区和Java堆就是所有线程共享的数据区域。

Class文件格式

Java(.java)文件被编译后会生成Class(.class)文件,这种二进制格式文件不依赖于特定的硬件和操作系统。每个Class文件中都对应着唯一的类或者接口的定义信息(内部类和匿名内部类编译后也会生成单独的Class文件)。但是类或者接口并不一定定义在文件中,比如类和接口可以通过类加载器直接生成。Class文件的格式如下:


Class文件的格式

ClassFile具有很强的描述能力,包含了很多关键的信息,其中部分字段前面的u4,u2表示"基本数据类型",class文件的基本数据类型如下:

类的生命周期

一个Java类的Class文件被加载到Java虚拟机内存中到从内存中卸载的过程被称为类的生命周期。包括以下几个阶段:加载、链接、初始化、使用和卸载。其中链接又可以分为三个阶段:验证、准备和解析。


类的生命周期

广义上来说,也是经常被面试问到的,类的加载机制是指:加载、验证、准备、解析和初始化这5个阶段。这几个阶段分别完成以下工作:
(1)加载:查找并加载Class文件。加载阶段主要做了三件事:

(2)链接:包括验证、准备和解析。

(3)初始化:将类变量初始化为正确的初始值。这个阶段主要包括两个过程:

类加载子系统

类加载仔细听通过多种类型的类加载器来完成查找和加载Class文件到Java虚拟机中。Java中有三种默认实现的类加载器:
(1)Bootstrap ClassLoader(引导类加载器)
用C/C++代码实现的加载器,用于加载指定的JDK核心库,比如java.lang、java.util等这些系统类。它用来加载以下路径下的类库:

(2)Extensions ClassLoader(扩展类加载器)
用于加载Java的扩展库,提供除了系统类之外的额外功能。它用来加载以下路径下的类库:

(3)Application ClassLoader(应用类加载器)
又称作System ClassLoader,因为这个类加载器可以通过ClassLoader.getSystemClassLoader()方法获取到。它由来加载以下路径下的类库:

除了系统自带的类加载器,用户还可以实现自定义加载器,它是通过继承java.lang.ClassLoader类的方式来实现自己的类加载器。

运行时数据区域

把Java的内存简单分为堆内存(Heap)和栈内存(Stack),这种说法不够准确。Java的内存区域划分实际上远比这复杂。Java虚拟机在执行Java程序的过程中,会对它所管理的内存划分为不同的数据区域。

程序计数器

为了保证程序能够连续地执行下去,处理器必须具有某些手段来确定下一条执行的地址,这就是程序计数器的作用。它是一块很小的内存空间。在虚拟机概念模型中,字节码解释器工作时(执行字节码指令)就是通过改变程序计数器里存储的地址,来选取下一条需要的字节码指令。Java虚拟机的多线程是通过轮流切换并分配处理器执行时间的方式实现的。在一个确定的时刻,只有一个处理器执行一个线程中的指令,为了在线程来回切换后能恢复到正确的执行位置,每个线程都有一个独立的程序计数器,也就是说,程序计数器是每个线程私有的。如果线程执行的方法不是Native方法,则程序计数器保存正在执行的字节码指令地址,如果是Native方法,则程序计数器的值为空(Undefined)。程序计数器是Java虚拟机规范中唯一没有规定任何OutOfMemoryError情况的数据区域。

Java虚拟机栈

每个Java虚拟机线程都有一个线程私有的Java虚拟机栈。它的生命周期和线程相同,与线程同时创建。Java虚拟机栈存储的是线程中Java方法的调用状态,包括局部变量、参数、返回值以及运算的中间结果等。一个Java虚拟机栈包含了多个栈帧,一个栈帧用来存储一个方法的局部变量表、操作数栈、动态链接、方法出口等信息。当线程执行一个Java方法时,虚拟机会压入一个新的栈帧到该线程的Java虚拟机栈中,该方法执行完成后,这个栈帧就会从Java虚拟机栈中弹出。我们平时所说的栈内存指的就是Java虚拟机栈,Java虚拟机规范中,定义了两种异常情况:

本地方法栈

Java虚拟机实现可能要用到C Stacks来支持Native语言,这个C Stacks就是本地方法栈(Native Method Stack)。它与Java虚拟机栈类似,只不过本地方法栈是用来支持Native方法的。如果Java虚拟机不支持Native方法,并且也不依赖于C Stacks,可以无须支持本地方法栈。在Java虚拟机规范中对本地方法栈的语言和数据结构等没有强制规定,因此具体的Java虚拟机可以自由实现它。与Java虚拟机栈类似,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。

Java堆

Java堆(Java Heap)是被所有线程共享的运行时内存区域,Java堆用来存放对象实例,几乎所有的对象实例都在这里分配内存。Java堆内存的对象被垃圾回收器管理,这些受管理的对象无法显式地销毁。从内存回收的角度来分,Java堆可以粗略的分为两部分:新生代和老年代。从内存分配的角度,Java堆中可能划分出多个线程私有的分配缓冲区。不管如何划分,Java对存储的内容是不变的,进行划分是为了能更快地回收或者分配内存。Java堆的容量可以是固定的,也可以动态扩展。Java堆所使用的内存在物理上不需要连续,逻辑上连续即可。Java虚拟机规范中定义了一种异常情况:如果在堆中没有足够的内存来完成实例分配,并且堆也无法进行扩展时,则会抛出OutOfMemoryError异常。

方法区

方法区(Method Area)是被所有线程共享的运行时内存区域,用来存储已经被Java虚拟机加载过的类的结构信息,包含运行时常量池、字段和方法信息、静态变量等数据。方法区是Java堆的逻辑组成部分,他一样在物理上不需要连续,并且可以选择在方法区中不实现垃圾回收。方法区并不等于永久代。只是因为HotSpot VM使用永久代来实现方法区,对于其他的Java虚拟机,比如J9和JRockit等,并不存在永久代概念。

运行时常量池

运行时常量池(Runtime Constant Pool)并不是运行时数据区域的其中一份子,而是方法区的一部分。Class文件不仅包含类的版本、接口、字段和方法等信息,还包含常量池,它用来存放编译时期生成的字面量(int i= 1;就是把1赋值给变量i,这个1就是字面量)和符号引用,这些内容会在类加载后存放在方法区的运行时常量池中。运行时常量池可以理解为是类或接口的常量池运行时的表现形式。在Java虚拟机规范中转定义了一种异常情况:当创建类或接口时,如果构造运行时常量池所需要的内存超狗方法区所能提供的最大值,Java虚拟机机会抛出OutOfMemoryError。

对象的创建

通常是通过new指令来完成一个对象的创建。当虚拟机收到一个new指令时,它会做如下操作:
(1)判断对象所属的类是否被加载、链接和初始化
虚拟机接收到一条new指令时,首先会检查这个指定的参数(类名)是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被类加载器加载、链接和初始化。
(2)为对象分配内存
类加载完成后,接着JVM会在Java堆中划分一块内存给对象。内存分配根据Java堆是否规整,有两种方式。

(3)处理并发安全问题
创建对象是一个非常频繁的操作,所以需要解决并发的问题,有两种方式:

(4)初始化分配到的内存空间
将分配到的内存,除了对象头外都初始化为零值。
(5)设置对象的对象头
将对象的所属类,对象的HashCode和对象的GC分代年龄等数据存储在对象的对象头中。
(6)执行init方法进行初始化
执行init方法,初始化对象的成员变量、调用类的构造方法,这样一个对象就创建出来了。

对象在堆内存的布局

以HotSpot虚拟机为例,一个对象在堆内存的分布分为三个区域:对象头、实例数据、对齐填充。

oop-Klass模型
oop-Klass模型是用来描述Java对象实例的一种模型,它分为两个部分,OOP(Ordinary Object Pointer)指的是普通对象指针,用来表示对象的实例信息。klass用来描述元数据。在Java虚拟机内部会分别定义很多oop类型和Klass类型,它们可以看做是oop家族和klass家族 oop家族
其中oopDesc是所有oop的顶级父类,arraryOopDesc是objArrayOopDesc和typeArrayOopDesc的父类。instanceOopdesc和arrayOopDesc都可以用来描述对象头。 klass家族
其中Klass是klass家族的父类(不是顶级父类),可以发现oop家族中的成员和klass家族的成员有着对应的关系,比如instanceOopDesc对应着instanceKlass。
当我们使用new创建一个对象的时候,JVM会在对重创建一个instanceOopDesc对象,这个对象中包含对象头以及实例数据。而我们从oop家族的关系可以看到,instanceOopDesc的父类是oopDesc。它的结构如下: oopDesc
oopDesc中包含两个数据成员:_mark和_metadata。其中markOop类型的_mark对象指的就是对象的对象头中的Mark Word。metadata是一个共用体,其中_klass是普通指针,_compressed_klass是压缩类指针,他们就是对象头中的元数据指针。这两个指针数据根据对应关系都会指向instanceKlass,instanceKlass可以用来描述元数据。 instanceKlass
instanceKlass继承自Klass,枚举ClassState用来标识对象的加载进度,Klass中的定义的部分字段如下: Klass
可以看到Klass描述了Java类的元数据,具体来说就是Java类在JVM中对等的C++类型描述,这样继承自Klass的instanceKlass同样可以用来描述元数据。了解了oop-klass模型,我们就可以分析JVM是如何通过栈帧中的对象引用找到对应的对象实例的。 通过对象引用确定对象具体类型
从图中可以看出,JVM通过栈帧中的对象引用找到Java堆中的instanceOopDesc对象,这样就可以访问到Java对象的实例信息,当需要访问对象的具体类型等信息时,可以通过instanceOopDesc的元数据指针来找到方法区中对应的instanceKlass。
垃圾标记算法

垃圾回收器,简称GC,主要做了两个工作,一个是内存的划分和分配,另一个是对垃圾进行回收。关于内存的划分和分配,目前JVM的内存划分是依赖于GC设计的,比如现在GC都是采用了分代收集算法来回收垃圾的,Java堆作为GC主要管理的区域被划分为新生代和老年代。而新生代又可以细分为Eden空间、FromSurvivor空间和ToSurvivor空间等。这样划分是为了更快地进行内存分配和回收。空间划分后,GC就可以为新对象分配内存空间。关于垃圾回收,被引用的对象是存活的对象,没有被引用的对象就是死亡的对象,也就是垃圾。GC要区分出存活的对象和死亡的对象(也就是垃圾标记),并对垃圾进行回收。目前垃圾标记有两种算法分别是引用计数算法和根搜索算法。这两种算法都和引用有些关联,所以我们可以先回顾下引用的相关知识点。

Java中的引用

JDK1.2之后,Java将引用分为强引用,软引用,弱引用,虚引用。

引用计数算法

引用计数算法的基本思想是,每个对象都有一个引用计数器,当对象在某处被引用的时候,它的引用计数器就加1,引用失效时就减1,当引用计数器中的值为0,则该对象就不能被使用,变成了垃圾。
目前主流的JVM没有选择引用计数算法来为垃圾标记的,主要原因是引用计数算法没有解决对象之间的相互循环引用的问题。

根搜索算法
这个算法的基本思想是选定一些对象作GC Roots,并组成根对象集合,然后以这些GC Roots的对象作为起始点,向下探索,如果目标对象到GC Roots是连接着的,我们则称该对象是可达的,如果目标对象不可达则说明目标对象是可以回收的对象。 根对象集合

该算法可以解决引用计数算法无法解决的问题:已经死亡的对象因为相互引用而不能被回收。在Java中,可以作为GC Roots的对象主要有这么几种:

还有一个问题是被标记为不可达的对象会立即被GC回收吗?要回答这个问题我们需要了解Java对象在JVM中的生命周期。

Java对象在JVM中的生命周期
  1. 创建阶段
    创建阶段的具体步骤为
    (1)为对象分配内存空间
    (2)构造对象
    (3)从超类到子类对static成员进行初始化
    (4)超类成员变量按顺序初始化,递归调用超类的构造方法
    (5)子类成员按顺序初始化,子类构造方法调用
  2. 应用阶段
    当对象被创建,并分配给变量赋值是,状态就切换到了应用状态。这个阶段的对象至少要具有一个强引用,或者显式地使用软引用,弱引用或者虚引用。
  3. 不可见阶段
    在程序中找不到对象的任何强引用,比如程序的执行已经超过了该对象的作用域。在不可见状态,对象仍可能被特殊的强应用GC Roots持有者,比如对象被本地方法栈中JNI引用或被运行中的线程引用等。
  4. 不可达阶段
    在程序中找不到对象的任何强引用,并且GC发现对象不可达。
  5. 收集阶段
    GC已经发现对象不可达,并且GC已经准备好要对该对象的内存空间进行重新分配,这个时候如果该对象重写了finalize方法,则会调用该方法。
  6. 终结阶段
    对象执行完finalize方法后仍然处于不可达状态时,或者对象没有重写finalize方法,则对象会进入终结阶段,等待GC来回收该对象的空间。
  7. 对象空间重新分配阶段
    当GC对对象的内存空间进行回收或者再分配时,这个对象就会彻底消失了。

我们现在已经了解了对象的生命周期,再来思考下先前那个问题,被标记为不可达的对象是否会立即被GC回收?很显然是不会的,被标记为不可达的对象会进入收集阶段,此时会执行该对象重写的finalize方法,如果没有重写finalize方法或者finalize方法中没有重新与一个可达的对象进行关联才会进入终结阶段,并最终被回收。

垃圾收集算法
标记-清除算法

标记-清除算法(Mark-Sweep)是一种常见的基础垃圾回收算法。它将垃圾回收分为两个阶段:

复制算法
为了解决标记-清除算法效率不高的问题,产生了复制算法。它把内存空间划分了两个相等的区域,每次只使用其中一个区域。在垃圾收集时,遍历当前使用的区域,把存活的对象复制到另一个区域中,最后将当前使用区域的可回收对象进行回收。执行过程如下图。 复制算法的执行过程

这种算法每次都对整个半区进行内存回收,不需要考虑内存碎片问题,代价就是使用内存为原来的一半。复制算法的效率和存活对象的数量多少有很大关系,如果存活对象很少,复制算法的效率就会很高。由于绝大多数对象的生命周期很短,并且这些生命周期很短的对象都存在于新生代中,所以复制算法被广泛应用于新生代中。

标记-压缩算法
在新生代中可以使用复制算法,但是在老年代中就不能选择复制算法了,因为老年代的对象存活率较高,这样会有较多的复制操作,导致效率较低。因此就出现了一种标记-清除算法的改进版,标记-压缩算法。与标记-清除算法不同的是,在标记可回收对象后,将所有存活的对象压缩到内存空间的一端,使它们紧凑地排列在一起,然后对边界之外的内存空间进行回收,回收后,已使用和未使用的内存就各自一边,标记-压缩算法的执行过程如下图。 标记-压缩算法执行过程

标记-压缩算法解决了标记-清除算法效率低和容易产生大量内存碎片的问题,它被广泛应用于老年代的垃圾回收。

分代收集算法

分代收集算法结合不同的收集算法来处理不同的空间。了解分代收集算法之前,我们先要了解Java堆内存的空间划分。在java虚拟机中,各种对象的生命周期会有较大的差异,大部分对象生命周期很短暂,少部分对象生命周期很长,有的甚至和应用程序以及JVM的运行周期一样长。因此,应该对不同生命周期的对象采用不同的回收策略,根据生命周期长短将它们分别放在Java堆内存的不同划分区域,并且在不同的区域采用不同的垃圾回收算法,这就是分代的概念。现在主流的Java虚拟机的GC都采用了分代收集算法。Java堆内存基于分代的概念,分为新生代和老年代,其中新生代又细分为Eden空间、From Survivor空间和To Survivor空间。新创建的对象通常先进入Eden空间,因为Eden中大多数对象生命周期很短,所以新生代的空间划分不是均分的,比如HotSpot虚拟机默认Eden空间和两个Survivor空间所在的比例是8:1:1。
根据Java堆内存区域的空间划分,垃圾回收的类型有两种:

当执行一次Minor Collection时,Eden空间的存活对象会被复制到To Survivor空间,同时,之前经过一次Minor Collection并在From Survivor空间存活的对象也会复制到To Survivor空间。有两种情况Eden空间和From Survivor空间存活的对象不会复制到To Survivor,而是晋升到老年代。一种是存活对象的分代年龄超过了所指定的阈值。另一种是To Survivor空间容量达到阈值。当所有存活对象被复制到To Survivor空间,或晋升到老年代,也就意味着Eden空间和From Survivor空间剩下的都是可回收对象。 复制算法在新生代中的应用

这个时候GC执行Minor Collection,Eden空间和FromSurvivor空间都会被清空,新生代中存活的对象都存放在To Survivor空间或晋升到老年代,接下来将From Survivor和To Survivor空间互换位置,也就是此前的From Survivor空间成为了现在的To Survivor空间,每次Minor Collection的执行,Survivor空间互换都保证了To Survivor空间是空的,这就是复制算法在新生代中的应用。而在老年代则会采用标记-压缩算法或者标记-清除算法。

java对象内存申请过程

1.JVM会试图为对象在Eden空间中分配一块内存区域;当Eden空间足够时,内存申请结束。否则到下一步;
2.JVM试图释放在Eden中所有不活跃的对象(minor collection),释放后若Eden空间仍然不足以放入新对象,则试图将部分Eden空间中活跃对象放入To Survivor空间;
3.To Survivor区被用来作为Eden空间及老年代的中间交换区域,当老年代空间足够时,To Survivor空间的对象会被移到老年代,否则会被保留在To Survivor空间;
4.当老年代空间不够时,JVM会在老年代进行major collection;
5.垃圾收集后,若To Survivor空间及老年代仍然无法存放从Eden空间复制过来的部分对象,导致JVM无法在Eden区为新对象创建内存区域,则出现"Out of memory错误";
本文参考
《Android进阶解密》
https://www.jianshu.com/p/d686e108d15f

上一篇下一篇

猜你喜欢

热点阅读