概述
我们常说的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文件的基本数据类型如下:
- u1:1字节,无符号类型
- u2:2字节,无符号类型
- u4:4字节,无符号类型
- u8:8字节,无符号类型
类的生命周期
一个Java类的Class文件被加载到Java虚拟机内存中到从内存中卸载的过程被称为类的生命周期。包括以下几个阶段:加载、链接、初始化、使用和卸载。其中链接又可以分为三个阶段:验证、准备和解析。
类的生命周期
广义上来说,也是经常被面试问到的,类的加载机制是指:加载、验证、准备、解析和初始化这5个阶段。这几个阶段分别完成以下工作:
(1)加载:查找并加载Class文件。加载阶段主要做了三件事:
- 根据特定名称查找类或接口类型的二进制字节流,这件事是由Java虚拟机外部的类加载子系统来完成的。
- 将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
(2)链接:包括验证、准备和解析。
- 验证:验证载入的Class文件数据的正确性。
- 准备:为类的静态变量分配内存,并用数据类型默认值初始化这些字段。
- 解析:虚拟机将常量池内的符号引用替换为直接引用。(符号引用是用一组符号描述所引用的目标;直接引用是指向目标的指针)
(3)初始化:将类变量初始化为正确的初始值。这个阶段主要包括两个过程:
- 如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;
- 如果类中存在初始化语句,就依次执行这些初始化语句。
类加载子系统
类加载仔细听通过多种类型的类加载器来完成查找和加载Class文件到Java虚拟机中。Java中有三种默认实现的类加载器:
(1)Bootstrap ClassLoader(引导类加载器)
用C/C++代码实现的加载器,用于加载指定的JDK核心库,比如java.lang、java.util等这些系统类。它用来加载以下路径下的类库:
- $JAVA_HOME/jre/lib目录
- -Xbootclasspath参数指定的目录
Java虚拟机的启动就是通过引导类加载器创建一个初始类来完成的。由于类加载器是使用平台相关的C/C++语言实现的。所以该加载器不能被java代码访问到,但是我们可以查询某个类是否被引导类加载器加载过。
(2)Extensions ClassLoader(扩展类加载器)
用于加载Java的扩展库,提供除了系统类之外的额外功能。它用来加载以下路径下的类库:
- $JAVA_HOME/jre/lib/ext目录
- 系统属性java.ext.dir所指定的目录
(3)Application ClassLoader(应用类加载器)
又称作System ClassLoader,因为这个类加载器可以通过ClassLoader.getSystemClassLoader()方法获取到。它由来加载以下路径下的类库:
- 当前应用程序Classpath目录
- 系统属性java.class.path指定的目录
除了系统自带的类加载器,用户还可以实现自定义加载器,它是通过继承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虚拟机所允许的最大容量,Java虚拟机就会抛出StackOverflowError。
- 如果Java虚拟机栈可以动态扩展(大部分Java虚拟机的虚拟机栈都可以动态扩展),但是扩展时无法申请到足够的内存,或者在创建新的线程时没有足够的内存来创建对应的虚拟机栈,则会抛出OutOfMemoryError。
本地方法栈
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堆是否规整,有两种方式。
- 指针碰撞:如果Java对的内存是规整的,即所有正在使用的内存放在一边,而空闲的内存放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成了分配内存工作。
- 空闲列表:如果Java堆内存不是规整的,则需要由虚拟机维护一个列表来记录哪些内存是空闲可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。
Java堆内存是否规整与JVM锁采用的垃圾回收器是否带有压缩整理功能有关。
(3)处理并发安全问题
创建对象是一个非常频繁的操作,所以需要解决并发的问题,有两种方式:
- 对分配内存空间的动作进行同步处理,比如在虚拟机采用CAS算法并配上失败重试的方式保证更新操作的原子性。
- 每个线程在Java堆中预先分配一小块内存,这块内存成为本地线程分配缓冲(Therad Local Allocation Buffer,TLAB),线程需要为对象分配内存时,就在对应线程的TLAB上分配内存,当线程的TLAB用完并且被分配到了新的TLAB时,这时候才需要同步锁定。通过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB。
(4)初始化分配到的内存空间
将分配到的内存,除了对象头外都初始化为零值。
(5)设置对象的对象头
将对象的所属类,对象的HashCode和对象的GC分代年龄等数据存储在对象的对象头中。
(6)执行init方法进行初始化
执行init方法,初始化对象的成员变量、调用类的构造方法,这样一个对象就创建出来了。
对象在堆内存的布局
以HotSpot虚拟机为例,一个对象在堆内存的分布分为三个区域:对象头、实例数据、对齐填充。
- 对象头:对象头包括两部分信息,分别是Mark Word和元数据指针。Mark Word用于存放对象运行时的数据、比如HashCode、锁状态标志、GC分代年龄、线程持有的锁(monitor)等。而元数据指针用于指向方法区中的目标类的元数据,通过元数据可以确定对象的具体类型。
- 实例数据:用于存放对象中的各种类型的字段信息,包括从父类继承来的。
- 对齐填充:对齐填充不一定存在,起到了占位符的作用,没有特别的含义。
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将引用分为强引用,软引用,弱引用,虚引用。
- 强引用:当我们新建一个对象时就创建了一个具有强引用的对象,如果一个对象具有强引用,GC就绝不会回收它。JVM宁愿抛出OOM异常,使程序异常终止,也不会回收具有强引用的对象来解决内存不足的问题。
- 软引用:如果一个对象只具有软引用,当内存不够时,GC会回收这些对象的内存,回收后如果还没有足够的内存,就会抛出OOM异常。Java提供了SoftReference类来实现软引用。
- 弱引用:弱引用比软引用的生命周期更短,GC一旦发现对象只具有弱引用,不管当前是否足够,都会回收它的内存。Java提供了WeakReference类来实现弱引用。
- 虚引用:虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,这就和没有任何引用一样,在任何时候都有可能被GC回收。一个只具有虚引用的对象,被GC回收时会发出一个系统通知,这也是虚引用的主要作用。Java提供了PhantomReference类来实现虚引用。
引用计数算法
引用计数算法的基本思想是,每个对象都有一个引用计数器,当对象在某处被引用的时候,它的引用计数器就加1,引用失效时就减1,当引用计数器中的值为0,则该对象就不能被使用,变成了垃圾。
目前主流的JVM没有选择引用计数算法来为垃圾标记的,主要原因是引用计数算法没有解决对象之间的相互循环引用的问题。
根搜索算法
这个算法的基本思想是选定一些对象作GC Roots,并组成根对象集合,然后以这些GC Roots的对象作为起始点,向下探索,如果目标对象到GC Roots是连接着的,我们则称该对象是可达的,如果目标对象不可达则说明目标对象是可以回收的对象。 根对象集合该算法可以解决引用计数算法无法解决的问题:已经死亡的对象因为相互引用而不能被回收。在Java中,可以作为GC Roots的对象主要有这么几种:
- 虚拟机栈中引用的对象
- 本地方法栈中JNI引用的对象
- 方法区静态变量引用的对象
- 方法区运行时常量池引用的对象
- 正在运行的线程
- 有Boorstrap ClassLoader加载的对象
- GC控制的对象
还有一个问题是被标记为不可达的对象会立即被GC回收吗?要回答这个问题我们需要了解Java对象在JVM中的生命周期。
Java对象在JVM中的生命周期
- 创建阶段
创建阶段的具体步骤为
(1)为对象分配内存空间
(2)构造对象
(3)从超类到子类对static成员进行初始化
(4)超类成员变量按顺序初始化,递归调用超类的构造方法
(5)子类成员按顺序初始化,子类构造方法调用 - 应用阶段
当对象被创建,并分配给变量赋值是,状态就切换到了应用状态。这个阶段的对象至少要具有一个强引用,或者显式地使用软引用,弱引用或者虚引用。 - 不可见阶段
在程序中找不到对象的任何强引用,比如程序的执行已经超过了该对象的作用域。在不可见状态,对象仍可能被特殊的强应用GC Roots持有者,比如对象被本地方法栈中JNI引用或被运行中的线程引用等。 - 不可达阶段
在程序中找不到对象的任何强引用,并且GC发现对象不可达。 - 收集阶段
GC已经发现对象不可达,并且GC已经准备好要对该对象的内存空间进行重新分配,这个时候如果该对象重写了finalize方法,则会调用该方法。 - 终结阶段
对象执行完finalize方法后仍然处于不可达状态时,或者对象没有重写finalize方法,则对象会进入终结阶段,等待GC来回收该对象的空间。 - 对象空间重新分配阶段
当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:新生代垃圾回收。
- Full Collection:又称作Major Collection,对老年代进行垃圾回收。Full Collection通常情况下会伴随至少一次的Minor Collection,它的收集频率较低,耗时较长。
这个时候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
网友评论