概述
我们常说的JDK(Java Development Kit)java开发工具包,它是包含JRE(Java Runtime Environment)和开发工具和Java语言,而JRE又包含Jvm和核心类库。下面是三者之间的关系:
JDK java development kit java开发工具包
JRE java runtime environment java运行环境
JRE = jvm + 核心类库
JDK = JRE + java 开发工具(比如javac.exe,jar.exe)+Java语言
JVM不是跨平台的,java程序之所以跨平台是因为,不同的平台上都安装了相应的jvm,例如,windows平台上有windows版本的jvm,Linux平台上有linux版本的jvm,MAC平台上有Mac版本的jvm,jvm来解析和运行java程序,而不是由各个平台(操作系统)来解析和运行java程序。其实不仅是Java语言开发的程序可以被java 虚拟机解析和运行,其他语言,比如groovy,kotlin等语言开发的程序也能被java 虚拟机解析和执行,因为这些语言编译后生成的class文件能够被jvm解析和运行。所以,了解Java 虚拟机还是很有必要的。
目前主流的Java 虚拟机有:
- HotSpot VM 它是Oracle JDK,Open JDK中自带的虚拟机,是最主流和使用最广泛的Java 虚拟机。一般介绍Java 虚拟机的文章,如果不做特殊说明,都是介绍的HotSpot VM.
- J9 VM 是IBM 开发的虚拟机,目前是其主力发展的Java 虚拟机。J9 VM 的市场定位和 HotSpotVM 接近,它是一款设计上从服务器端到桌面应用再到嵌入式都考虑到的多用途虚拟机,目前J9VM 的性能水平大致与HotSpotVM 是一个档次的。
- Zing VM 以Oracle 的HotSpotVM 为基础,改进了许多影响延迟的细节。最大的3 个卖点如下:
a.低延迟,“无暂停”的C4 GC, GC 带来的暂停可以控制在10 ms 以下的级别,支持 的Java 堆大小可以达到 1TB 。
b·启动后快速预热功能。
c·可管理性:零开销、可在生产环境全时开启、整合在JVM 内的监控工具Zing Vision
Java虚拟机的执行流程
Java虚拟机的执行流程:编译和执行。当一个java程序,需要java编译器先将java文件编译成class文件,然后由java虚拟机解析和执行。无论何种语言,只要能编译成class文件,就能被java虚拟机识别和执行。
Java虚拟机的结构
Java 虚拟机结构包括运行时数据区域、执行引擎、本地库接口和本地方法库,其中类加载子系统并不属于Java 虚拟机的内部结构
Class文件的格式
ClassFile {
u4 magic ; //魔数,固定值为OxCAFEBABE ,用来判断当前文件是不是能被Java 虚拟机处理的Class 文件
u2 minor version ; //副版本号
u2 major version ; //主版本号
u2 constant pool count ; //常量池计数器
cp _info constant pool[constant pool count- 1]; //常量池
u2 access flags ; //类和接口层次的访问标志
u2 this class ; //类索引
u2 super class ; //父类索引
u2 interfaces count ; //接口计数器
u2 interfaces[interfaces count] ; //接口表
u2 fields count ; //字段计数器
field info fields[fields count] ; //字段表
u2 methods count ; //方法计数器
method 工nfo methods[methods count] ; //方法表
u2 attributes count; //属性计数器
attribute info attributes[attributes count] ; //属性表
可以看到ClassFile 具有很强的描述能力,包含了很多关键的信息,其中u4 、u2 表示
“基本数据类型”, class 文件的基本数据类型如下所示。
• ul: 1 字节,无符号类型。
• u2: 2 字节,无符号类型。
• u4: 4 字节,无符号类型。
• u8: 8 字节,无符号类型。
虚拟机的类加载机制
了解了Class文件的存储格式后,Class文件中的各种描述信息最终都需要加载到虚拟机中之后才能运行和使用,虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个就是虚拟机的类加载机制。虚拟机的类加载机制包括了:加载、链接(验证、准备、解析)、初始化三个大的阶段。
下面介绍类的加载机制各个阶段所在的工作:
1.加载:查找并加载Class文件。
2.链接:包括验证、准备和解析。
• 验证:确保被导入类型的正确性(包括文件格式验证、元数据验证、字节码验证、符号引用验证)。
• 准备:为类的静态字段分配内存(注意是静态字段,非静态字段的初始化过程是在创建对象的时候完成的),并用默认值初始化这些字段。
• 解析:虚拟机将常量池内的符号引用替换为直接引用。
3.初始化:将类变量初始化为正确初始值。
根据《深入理解Java 虚拟机》的描述,加载阶段(不是类的加载)主要做了3 件事情:
• 根据特定名称查找类或接口类型的二进制字节流。
• 将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构。
• 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
其中第一件事情就是由Java虚拟机外部的类加载子系统来完成的,下面我们来学习类加载子系统。
类加载子系统
类加载子系统通过多种类加载器来查找和加载Class文件到Java 虚拟机中,Java虚拟机有两种类加载器:系统加载器和自定义加载器。其中系统加载器包括以下三种。
-
Bootstrap Classloader (引导类加载器)
用C/C++代码实现的加载器,用于加载指定的JDK 的核心类库,比如java.lang.XXX , java.util.XXX 等这些系统类。它用来加载以下目录中的类库:
• $JAVA_HOME/jre/lib 目录。
• -Xbootclasspath 参数指定的目录。Java 虚拟机的启动就是通过引导类加载器创建一个初始类来完成的。由于类加载器是使用平台相关的底层C/C++语言实现的,所以该加载器不能被Java 代码访问到,但是我们可以查询某个类是否被引导类加载器加载过,因为加载的类会缓存到jvm中,通过Classloader类的findLoadedClass方法我们就可以获取到这个类是否被加载过。
-
Extensions Classloader (拓展类加载器)
用于加载Java 的拓展类,提供除了系统类之外的额外功能。它用来加载以下目录中的类库:
• 加载$JAVA_HOME/jre/lib/ext 目录。
• 系统属性java.ext.dir 所指定的目录。 -
Application Classloader (应用程序类加载器)
又称作System ClassLoader (系统类加载器),这是因为这个类加载器可以通过ClassLoader的getSystemClassLoader 方法获取到。它用来加载以下目录中的类库:
• 当前应用程序Classpath 目录。
• 系统属性java.class.path 指定的目录。
除了系统加载器还有自定义加载器,它是通过继承java.lang.ClassLoader 类的方式来实现自己的类加载器。
运行时数据区域
Java虚拟机在执行Java程序过程中,它会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途,以及创建和销毁时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据Java虚拟机规范的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域。
Java虚拟机运行时的数据区包括了方法区,Java 堆,虚拟机栈,本地方法栈,程序计数器。
-
方法区( Method Area )
是被所有线程共享的运行时内存区域,用来存储已经被Java虚拟机加载的类的结构信息,包括运行时常量池、字段和方法信息、静态变量等数据。方法区是Java 堆的逻辑组成部分,它一样在物理上不需要连续,并且可以选择在方法区中不实现垃圾收集。方法区并不等同于永久代,只是因为HotSpot VM 使用永久代来实现方法区,对于其他的Java 虚拟机,比如J9和JRockit 等,并不存在永久代概念。在Java 虚拟机规范中定义了一种异常情况:如果方法区的内存空间不满足内存分配需求时, Java 虚拟机会抛OutOfMemoryError 异常。 -
Java 堆(Java Heap )
是被所有线程共享的运行时内存区域。Java 堆用来存放对象实例,几乎所有的对象实例都在这里分配内存。Java 堆存储的对象被垃圾收集器管理,这些受管理的对象无法显式地销毁。从内存回收的角度来分, Java 堆可以粗略地分为新生代和老年代,从内存分配的角度Java 堆中可能划分出多个线程私有的分配缓冲区。不管如何划分,Java 堆存储的内容是不变的,进行划分是为了能更快地回收或者分配内存。Java 堆的容量可以是固定的,也可以动态扩展。Java 堆所使用的内存在物理上不需要连续,逻辑上连续即可。Java 虚拟机规范中定义了一种异常情况:如果在堆中没有足够的内存来完成实例分配,并且堆也无法进行扩展时,则会抛出OutOfMemoryError 异常。 -
虚拟机栈
每一条Java 虚拟机线程都有一个线程私有的Java 虚拟机栈(Java Virtual Machine Stacks )。它的生命周期与线程相同,与线程是同时创建的。Java 虚拟机栈存储线程中Java方法调用的状态,包括局部变量、参数、返回值以及运算的中间结果等。一个Java 虚拟机栈包含了多个栈帧, 一个栈帧用来存储局部变量表、操作数栈、动态链接、方法出口等信息。当线程调用一个Java方法时,虚拟机压入一个新的栈帧到该线程的Java 虚拟机栈中,在该方法执行完成后,这个栈帧就从Java 虚拟机栈中弹出。我们平常所说的栈内存( Stack)指的就是Java 虚拟机栈。Java 虚拟机规范中定义了两种异常情况。
a. 如果线程请求分配的栈容量超过Java 虚拟机所允许的最大容量, Java 虚拟机会抛出StackOverflowError 。
b.如果Java 虚拟机栈可以动态扩展(大部分Java虚拟机都可以动态扩展),但是扩展时无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的Java 虚拟机栈,则会抛出OutOfMemoryError异常。 -
本地方法栈
Java 虚拟机实现可能要用到C Stacks 来支持Native 语言,这个C Stacks 就是本地方法栈( Native Method Stack )。它与Java 虚拟机栈类似,只不过本地方法栈是用来支持Native方法的。如果Java 虚拟机不支持Native方法,并且也不依赖于C Stacks,可以无须支持本地方法栈。在Java 虚拟机规范中对本地方法栈的语言和数据结构等没有强制规定,因此具体的Java虚拟机可以自由实现它,比如HotSpot VM将本地方法栈和Java 虚拟机栈合二为一。与Java 虚拟机栈类似,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。 -
程序计数器
为了保证程序能够连续地执行下去,处理器必须具有某些手段来确定下一条指令的地址,而程序计数器正是起到这种作用。程序计数器( Program Counter Register)也叫作PC寄存器,是一块较小的内存空间。在虚拟机概念模型中,字节码解释器工作时就是通过改变程序计数器来选取下一条需要执行的字节码指令的,Java 虚拟机的多线程是通过轮流切换并分配处理器执行时间的方式来实现的,在一个确定的时刻只有一个处理器执行一条线程中的指令,为了在线程切换后能恢复到正确的执行位置,每个线程都会有一个独立的程序计数器,因此,程序计数器是线程私有的。如果线程执行的方不是Native方法,则程序计数器保存正在执行的字节码指令地址,如果是Native方法则程序计数器的值为空(Undefined)。程序计数器是Java虚拟机规范中唯一没有规定任何OutOfMemoryError 情况的数据区域。 -
运行时常量池( Runtime Constant Pool )
是方法区的一部分。Class 文件不仅包含类的版本、接口、宇段和方法等信息,还包含常量池,它用来存放编译时期生成的字面量和符号引用,这些内容会在类加载后存放在方法区的运行时常量池中。
对象的创建过程
对象的创建是我们经常要做的事,通常是通过new 指令来完成一个对象的创建的,当虚拟机接收到一个new 指令时,它会做如下的操作。
-
判断对象对应的类是否加载、链接和初始化
虚拟机接收到一条new 指令时,首先会去检查这个指定的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被类加载器加载、链接和初始化过。 -
为对象分配内存
类加载完成后,接着会在Java 堆中划分一块内存分配给对象。内存分配根据Java堆是否规整,有两种方式:
a. 指针碰撞:如果Java堆的内存是规整的,即所有用过的内存放在一边,而空闲的内在放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。
b. 空闲列表:如果Java 堆的内存不是规整的,则需要由虚拟机维护一个列表来记录哪些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。Java 堆的内存是否规整根据所采用的垃圾收集器是否带有压缩整理功能有关。 -
处理并发安全问题
创建对象是一个非常频繁的操作,所以需要解决并发的问题,有两种方式:
a.对分配内存空间的动作进行同步处理,比如在虚拟机采用CAS(Compare and Swap)算法,即比较并替换(乐观锁技术),并配上失败重试的方式保证更新操作的原子性。
b.每个线程在Java 堆中预先分配一小块内存,这块内存称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),线程需要分配内存时,就在对应线程的TLAB上分配内存,当线程中的TLAB 用完并且被分配到了新的TLAB 时,这时候才需要同步锁定。通过-XX:+/-UserTLAB 参数来设定虚拟机是否使用TLAB 。 -
初始化分配到的内存空间
将分配到的内存,除了对象头外都初始化为零值。 -
设置对象的对象头
将对象的所属类、对象的HashCode 和对象的GC 分代年龄等数据存储在对象的对象头中。 -
执行init方法进行初始化
执行init方法,初始化对象的成员变量、调用类的构造方法,这样一个对象就被创建了出来。
Java 中的引用
Java将引用分为强引用,软引用,弱引用,虚引用。
-
强引用:
新建一个对象时,就创建了一个具有强引用的对象。如果一个对象具有强引用,垃圾回收器即使抛出OutOfMemoryError异常,也不会对其进行回收。 -
软引用
Java提供SoftReference类实现软引用。如果一个对象只具有软引用,当内存不够时,垃圾回收器会回收这些软引用的对象,回收后的内存如果还不够,则会抛出OutOfMemoryError异常。
Object obj = new Object();
SoftReference sf = new SoftReference(obj);
Object o = sf.get();//有可能为null
- 弱引用
Java提供WeakReference来实现弱引用。弱引用比起软引用具有更短的声明周期,垃圾回收器一旦发现只具有弱引用的对象,不管内存是否足够都会回收这些对象。
Object obj = new Object();
WeakReference wf = new WeakReference(obj);
Object o = wf.get();//可能返回null
- 虚引用
Java提供PhantomReference来实现虚引用。虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,这就和没有任何引用一样,在任何时候都有可能会垃圾回收器回收。一个只具有虚引用的对象被垃圾回收器回收后,会收到一个系统通知,这就是虚引用的主要作用。
Object obj = new Object();
PhantomReference<Object> phantom = new PhantomReference<>(obj, new ReferenceQueue<>());
Object o = phantom.get();//返回的永远是null
垃圾标记算法
- 引用计数算法
引用计数算法的基本思想就是每个对象都有一个引用计数器,当对象在某处被引用时,它的引用计数器就加1,引用失效时就减1,当引用计数器中的值变为0时,则该对象就不能被使用,变成垃圾。目前主流的Java虚拟机没有选择引用计数算法来标记垃圾,主要是因为引用计数算法没有解决相互循环引用的问题。
2.根搜索算法(可达性分析算法)
这个算法的原理就是,选定一些对象作为GC Roots,并组成根对象集合,然后以这些GC Roots的对象作为起始点,向下搜索,如果目标对象到GC Roots是连接的,则称目标对象是可达的,如果目标对象不可达,则说明目标对象是可回收的。
图中,obj5,obj6,obj7是不可达的。其中obj5和obj6虽然是相互引用,但是是不可达的,所以这种算法就解决了引用计数算法的问题。
GC Roots的对象主要有如下几种:
- Java栈中引用的对象。
- 本地方法栈中JNI引用的对象。
- 方法区中运行时常量池引用的对象。
- 方法区中静态属性引用的对象。
- 运行中的线程。
- 引导类加载器加载的对象。
- GC控制的对象。
Java对象在虚拟机中的生命周期
在Java对象被类加载器加载到虚拟机中后,Java对象在Java虚拟机中有7个阶段:
-
创建阶段(Created)
创建阶段的具体步骤为:
a.为对象分配存储空间。
b.构造对象。
c.从超类到子类的static成员进行初始化。
d.递归调用超类的构造方法。
e.调用子类的构造方法。 -
应用阶段(In Use)
当对象被创建时,并给变量赋值时,就切换到了引用状态,这一阶段的对象至少具有一个强引用,或者显示使用软引用、弱引用、虚引用。 -
不可见阶段(Invisible)
在程序中找不到对象的任何强引用,比如程序的执行已经超出了该对象的作用域。在不可见阶段,对象仍然可能被特殊的强引用GC Roots持有着,比如对象被本地方法栈中JNI引用或者被运行中线程引用等。 -
不可达阶段(Unreachable)
在程序中找不到对象的任何强引用,并且垃圾收集器发现对象不可达。 -
收集阶段(Collected)
垃圾收集器已经发现对象不可达,并且垃圾收集器已经准备好要对该对象的内存空间重新进行分配,这个时候如果该对象重写了finalize方法,则会调用该方法。 -
终结阶段(Finalized)
在对象执行完finalize方法后,仍然处于不可达状态,或者对象没有重写finalize方法,则该对象进入终结阶段,并等待垃圾回收器回收该对象空间。 -
对象空间重新分配阶段(Deallocated)
当垃圾回收器对对象的内存空间进行回收或者再分配时,这个对象就彻底消失了。
被标记为不可达的对象会立即被垃圾回收器回收吗?
显然是不会的,被标记为不可达的对象,其生命周期只是进入到了终结阶段,这时会执行该对象的finalize方法,如果该对象没有重写finalize方法或者重写finalize方法中没有重新与一个可达对象关联,则会进入终结阶段,最终被垃圾回收器回收。
类的生命周期
一个Java 文件被加载到Java 虚拟机内存中到从内存中卸载的过程被称为类的生命周期。类的生命周期包括的阶段分别是:加载、链接、初始化、使用和卸载,其中链接包括了三个阶段:验证、准备和解析,因此类的生命周期包括了7 个阶段。广义上来说类的加载包括了类的生命周期的前5个阶段,分别是加载、链接(验证、准备和解析)、初始化。
类的加载阶段前面已经介绍过了,下面只是介绍下,类的使用和卸载阶段:
类的使用阶段:
在类的使用过程中必然存在三步:对象实例化、垃圾收集、对象终结。
- 对象实例化
就是执行类中构造函数的内容,如果该类存在父类,JVM会通过显示或者隐示的方式先执行父类的构造函数,在堆内存中为父类的实例变量开辟空间,并赋予默认的初始值,然后在根据构造函数的代码内容将真正的值赋予实例变量本身,然后,引用变量获取对象的首地址,通过操作对象来调用实例变量和方法 - 垃圾收集
当对象不再被引用的时候,就会被虚拟机标上特别的垃圾记号,在堆中等待GC回收 - 对象的终结
对象被GC回收后,对象就不再存在,对象的生命也就走到了尽头
类的卸载阶段,首先要判断这个类是不是"无用的类",判断条件如下:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
- 加载类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可对满足上面3个条件的无用类进行回收,但是仅仅是"可以",但不是和对象一样,不使用了就立刻回收。
是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制。
垃圾收集算法
1.标记——清除(Mark_Sweep)算法,这个过程分为两个阶段:标记和清除。
标记阶段就是通过根搜索算法(可达性分析算法),来完成对象是否是可回收的对象的标记工作。
清除阶段就是回收被标记的对象所占用的空间。
标记——清除算法是基础算法,后期的其他算法都是以这个算法作为基础来进行优化。
这个算法有两个缺点:
a.标记和清除的效率都不高
b.回收后的内存空间不够连续,当需要给一个大的对象分配内存时,就没有连续的大的内存空间,这样会提前触发一次新的GC。
2.复制算法,为了解决标记清楚算法的效率不高的问题,产生了复制算法,复制算法的原理是,将内存划分为两个相等的区域,每次只使用其中的一个区域,当垃圾收集时,会遍历其中一块已经使用的内存区域,将存活的对象复制到另外一半的内存区域,
并将当前内存区域一次清理掉。这种算法每次都是对整个半区进行内存回收,这样就可以得到大片的连续内存,由于是对半个区域一次清理,这样效率就比对不连续的内存区域进行清理效率要高。由于新生代中的对象的生命周期很短,这种算法被用于新生代中。
这种算法也有两个缺点:
a.每次只使用一半的内存区域,内存使用是原来的一半。
b.当存活的对象较多时,就需要复制很多的对象,这样复制的效率就会很低。
3.标记压缩算法,在新生代中,使用复制算法,但是在老年代中,就不能使用复制算法了,因为老年代中的存活对象多,需要赋值的操作也会增多,这样导致复制效率就会很低,标记清楚算法虽然也能用在老年代中,但是由于效率低,并且会产生大量不连续的内存空间,因此出现了标记压缩算法,它的原理是,标记完所有的存活对象后,将这些存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对边界以外的区域进行回收,回收后,使用的内存空间和未使用的内存空间各占一端。这样就很好的解决了标记清除算法的两个缺点。这种算法被广泛的用于老年代中。
4.分代收集算法,Java根据对象的存活周期的不同将内存划分为几块,一般是将Java堆分为新生代和老年代。这就是分代的概念。这种算法根据各个年代的特点,使用不同的收集算法。其中,新生代又细分为Eden空间和两个Survivor空间,由于Eden空间存在很多生命周期很短的对象,所以新生代的空间并不是均分的,而是Eden空间和Survivor空间按照8:1划分的。由于新生代的生命周期大部分都很短,所以复制算法被广泛用于新生代中。
根据Java堆区的空间划分,垃圾收集类型分为两种,分别如下:
Minor GC:发生在新生代的垃圾收集动作,因为Java对象大多都具有朝生夕灭的特性,所以Minor GC 非常的频繁,并且速度也很快。
Full GC(Major GC):指发生在老年代的垃圾收集动作,通常情况下,出现Full GC就会伴随至少一次的Minor GC,Full GC的回收频率较低,耗时较长。
当执行一次Minor GC时,Eden空间和Survivor空间的存活对象会被复制到另外一个Survivor空间。有两种情况,Eden空间和Survivor空间的存活对象不能复制到另外一个Survivor空间,一种是,存活的对象的"年龄太大(一般超过15)",这个年龄可以通过-XX:MaxTenuringThreshold所指定的阀值来确定,这样的对象就直接被分配到老年代空间中,还有一种情况是,另外一个Survivor空间不够,会将部分存活的对象会分配到老年代。如果Eden空间和Survivor空间的所有存活对象都被复制到了另外一个Survivor空间,或者晋升为老年代,那么当Minor GC时,Eden空间和Survivor空间将会被清空。后面在次发生Minor GC时,仍然重复这个过程,不同的是存活的对象被复制到了一个完全"干净"的Survivor空间中。这就是复制算法在新生代的运用。
参考:《深入理解Java虚拟机》、《Android进阶解密》