深入浅出 Java 虚拟机 是你通往高级 Java 开发的必经之路

发布时间:2020-08-04 15:33:07 作者:java周某人
来源:网络 阅读:3930

深入浅出 Java 虚拟机 是你通往高级 Java 开发的必经之路


干货来咯

深入浅出 Java 虚拟机 是你通往高级 Java 开发的必经之路


前言:

今天要给大家分享的是Java虚拟机的一些硬货知识,文章不错的话记得给我点给个关注哦,私信我可以获取更多的java资料。

第一章 JVM 内存模型

Java 虚拟机(Java Virtual Machine=JVM)的内存空间分为五个部分,分别是:

  1. 程序计数器

  2. Java 虚拟机栈

  3. 本地方法栈

  4. 方法区。

下面对这五个区域展开深入的介绍。

1.1 程序计数器

1.1.1 什么是程序计数器?

程序计数器是一块较小的内存空间,可以把它看作当前线程正在执行的字节码的行号指示器。也就是说,程序计数器里面记录的是当前线程正在执行的那一条字节码指令的地址。

注:但是,如果当前线程正在执行的是一个本地方法,那么此时程序计数器为空。

1.1.2 程序计数器的作用

程序计数器有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。

  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

1.1.3 程序计数器的特点

  1. 是一块较小的存储空间

  2. 线程私有。每条线程都有一个程序计数器。

  3. 是唯一一个不会出现OutOfMemoryError的内存区域。

  4. 生命周期随着线程的创建而创建,随着线程的结束而死亡。

1.2 Java虚拟机栈(JVM Stack)

1.2.1 什么是Java虚拟机栈?

Java虚拟机栈是描述Java方法运行过程的内存模型。

Java虚拟机栈会为每一个即将运行的Java方法创建一块叫做“栈帧”的区域,这块区域用于存储该方法在运行过程中所需要的一些信息,这些信息包括:

  1. 局部变量表

  2. 存放基本数据类型变量、引用类型的变量、returnAddress类型的变量。

  3. 操作数栈

  4. 动态链接

  5. 方法出口信息

当一个方法即将被运行时,Java虚拟机栈首先会在Java虚拟机栈中为该方法创建一块“栈帧”,栈帧中包含局部变量表、操作数栈、动态链接、方法出口信息等。当方法在运行过程中需要创建局部变量时,就将局部变量的值存入栈帧的局部变量表中。

当这个方法执行完毕后,这个方法所对应的栈帧将会出栈,并释放内存空间。

注意:人们常说,Java的内存空间分为“栈”和“堆”,栈中存放局部变量,堆中存放对象。

这句话不完全正确!这里的“堆”可以这么理解,但这里的“栈”只代表了Java虚拟机栈中的局部变量表部分。真正的Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。

1.2.2 Java 虚拟机栈的特点

(1)局部变量表的创建是在方法被执行的时候,随着栈帧的创建而创建。而且,局部变量表的大小在编译时期就确定下来了,在创建的时候只需分配事先规定好的大小即可。此外,在方法运行的过程中局部变量表的大小是不会发生改变的。

(2)Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

(3)Java虚拟机栈也是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

注:StackOverFlowError和OutOfMemoryError的异同?StackOverFlowError表示当前线程申请的栈超过了事先定好的栈的最大深度,但内存空间可能还有很多。而OutOfMemoryError是指当线程申请栈时发现栈已经满了,而且内存也全都用光了。

1.3 本地方法栈

1.3.1 什么是本地方法栈?

本地方法栈和Java虚拟机栈实现的功能类似,只不过本地方法区是本地方法运行的内存模型。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间。

也会抛出StackOverFlowError和OutOfMemoryError异常。

1.4 堆

1.4.1 什么是堆?

堆是用来存放对象的内存空间。

几乎所有的对象都存储在堆中。

1.4.2 堆的特点

(1)线程共享

整个 Java 虚拟机只有一个堆,所有的线程都访问同一个堆。而程序计数器、Java 虚拟机栈、本地方法栈都是一个线程对应一个的。

(2)在虚拟机启动时创建。

(3)垃圾回收的主要场所。

(4)可以进一步细分为:新生代、老年代。

新生代又可被分为:Eden、From Survior、To Survior。不同的区域存放具有不同生命周期的对象。这样可以根据不同的区域使用不同的垃圾回收算法,从而更具有针对性,从而更高效。

(5)堆的大小既可以固定也可以扩展,但主流的虚拟机堆的大小是可扩展的,因此当线程请求分配内存,但堆已满,且内存已满无法再扩展时,就抛出 OutOfMemoryError。

1.5 方法区

1.5.1 什么是方法区?

Java 虚拟机规范中定义方法区是堆的一个逻辑部分。方法区中存放已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等。

1.5.2 方法区的特点

  1. 线程共享

  2. 方法区是堆的一个逻辑部分,因此和堆一样,都是线程共享的。整个虚拟机中只有一个方法区。

  3. 永久代

  4. 方法区中的信息一般需要长期存在,而且它又是堆的逻辑分区,因此用堆的划分方法,我们把方法区称为老年代。

  5. 内存回收效率低

  6. 方法区中的信息一般需要长期存在,回收一遍内存之后可能只有少量信息无效。

  7. 对方法区的内存回收的主要目标是:对常量池的回收 和 对类型的卸载。

  8. Java虚拟机规范对方法区的要求比较宽松。

  9. 和堆一样,允许固定大小,也允许可扩展的大小,还允许不实现垃圾回收。

1.5.3 什么是运行时常量池?

方法区中存放三种数据:类信息、常量、静态变量、即时编译器编译后的代码。其中常量存储在运行时常量池中。

我们一般在一个类中通过public static final来声明一个常量。这个类被编译后便生成Class文件,这个类的所有信息都存储在这个class文件中。

当这个类被Java虚拟机加载后,class文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池中添加新的常量。如:String类的intern()方法就能在运行期间向常量池中添加字符串常量。

当运行时常量池中的某些常量没有被对象引用,同时也没有被变量引用,那么就需要垃圾收集器回收。

1.6 直接内存

直接内存是除Java虚拟机之外的内存,但也有可能被Java使用。

在NIO中引入了一种基于通道和缓冲的IO方式。它可以通过调用本地方法直接分配Java虚拟机之外的内存,然后通过一个存储在Java堆中的DirectByteBuffer对象直接操作该内存,而无需先将外面内存中的数据复制到堆中再操作,从而提升了数据操作的效率。

直接内存的大小不受Java虚拟机控制,但既然是内存,当内存不足时就会抛出OOM异常。

1.7 综上所述

  1. Java虚拟机的内存模型中一共有两个“栈”,分别是:Java虚拟机栈和本地方法栈。

  2. 两个“栈”的功能类似,都是方法运行过程的内存模型。并且两个“栈”内部构造相同,都是线程私有。

  3. 只不过Java虚拟机栈描述的是Java方法运行过程的内存模型,而本地方法栈是描述Java本地方法运行过程的内存模型。

  4. Java虚拟机的内存模型中一共有两个“堆”,一个是原本的堆,一个是方法区。方法区本质上是属于堆的一个逻辑部分。堆中存放对象,方法区中存放类信息、常量、静态变量、即时编译器编译的代码。

  5. 堆是Java虚拟机中最大的一块内存区域,也是垃圾收集器主要的工作区域。

  6. 程序计数器、Java虚拟机栈、本地方法栈是线程私有的,即每个线程都拥有各自的程序计数器、Java虚拟机栈、本地方法栈。并且他们的生命周期和所属的线程一样。

  7. 而堆、方法区是线程共享的,在Java虚拟机中只有一个堆、一个方法栈。并在JVM启动的时候就创建,JVM停止才销毁。


第二章 揭开Java对象创建的奥秘

2.1 对象的创建过程

当虚拟机遇到一条含有new的指令时,会进行一系列对象创建的操作:

(1)检查常量池中是否有即将要创建的这个对象所属的类的符号引用;

(2)进而检查这个符号引用所代表的类是否已经被JVM加载;

(3)根据方法区中该类的信息确定该类所需的内存大小;

一个对象所需的内存大小是在这个对象所属类被定义完就能确定的!且一个类所生产的所有对象的内存大小是一样的!JVM在一个类被加载进方法区的时候就知道该类生产的每一个对象所需要的内存大小。

(4)从堆中划分一块对应大小的内存空间给新的对象;分配堆中内存有两种方式:

(5)为对象中的成员变量赋上初始值(默认初始化);

(6)设置对象头中的信息;

(7)调用对象的构造函数进行初始化;

此时,整个对象的创建过程就完成了。

2.2 对象的内存模型

一个对象从逻辑角度看,它由成员变量和成员函数构成,从物理角度来看,对象是存储在堆中的一串二进制数,这串二进制数的组织结构如下。

对象在内存中分为三个部分:

  1. 对象头

  2. 实例数据

  3. 对齐补充

2.2.1 对象头

对象头中记录了对象在运行过程中所需要使用的一些数据:哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

此外,对象头中可能还包含类型指针。通过该指针能确定这个对象所属哪个类。

此外,如果对象是一个数组,那么对象头中还要包含数组长度。

2.2.2 实例数据

实力数据部分就是成员变量的值,其中包含父类的成员变量和本类的成员变量。

2.2.3 对齐补充

用于确保对象的总长度为8字节的整数倍。

HotSpot要求对象的总长度必须是8字节的整数倍。由于对象头一定是8字节的整数倍,但实例数据部分的长度是任意的,因此需要对齐补充字段确保整个对象的总长度为8的整数倍。

2.3 访问对象的过程

我们知道,引用类型的变量中存放的是一个地址,那么根据地址类型的不同,对象有不同的访问方式:

  1. 句柄访问方式

  2. 堆中需要有一块叫做“句柄池”的内存空间,用于存放所有对象的地址和所有对象所属类的类信息。

  3. 引用类型的变量存放的是该对象在句柄池中的地址。访问对象时,首先需要通过引用类型的变量找到该对象的句柄,然后根据句柄中对象的地址再访问对象。

  4. 直接指针访问方式

  5. 引用类型的变量直接存放对象的地址,从而不需要句柄池,通过引用能够直接访问对象。

  6. 但对象所在的内存空间中需要额外的策略存储对象所属的类信息的地址。

比较

HotSpot采用直接指针方式访问对象,因为它只需一次寻址操作,从而性能比句柄访问方式快一倍。但它需要额外的策略存储对象在方法区中类信息的地址。


第三章 揭开 Java 对象内存分配的秘密

Java所承诺的自动内存管理主要是针对对象内存的回收和对象内存的分配。

在Java虚拟机的五块内存空间中,程序计数器、Java虚拟机栈、本地方法栈内存的分配和回收都具有确定性,一半都在编译阶段就能确定下来需要分配的内存大小,并且由于都是线程私有,因此它们的内存空间都随着线程的创建而创建,线程的结束而回收。也就是这三个区域的内存分配和回收都具有确定性。

而Java虚拟机中的方法区因为是用来存储类信息、常量

静态变量,这些数据的变动性较小,因此不是Java内存管理重点需要关注的区域。

而对于堆,所有线程共享,所有的对象都需要在堆中创建和回收。虽然每个对象的大小在类加载的时候就能确定,但对象的数量只有在程序运行期间才能确定,因此堆中内存的分配具有较大的不确定性。此外,对象的生命周期长短不一,因此需要针对不同生命周期的对象采用不同的内存回收算法,增加了内存回收的复杂性。

综上所述:Java自动内存管理最核心的功能是堆内存中对象的分配与回收。

3.1 对象优先在 Eden 区中分配

目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代。

在新生代中为了防止内存碎片问题,因此垃圾收集器一般都选用“复制”算法。因此,堆内存的新生代被进一步分为:Eden区+Survior1区+Survior2区。

每次创建对象时,首先会在Eden区中分配。

若Eden区已满,则在Survior1区中分配。

若Eden区+Survior1区剩余内存太少,导致对象无法放入该区域时,就会启用“分配担保”,将当前Eden区+Survior1区中的对象转移到老年代中,然后再将新对象存入Eden区。

3.2 大对象直接进入老年代

所谓“大对象”就是指一个占用大量连续存储空间的对象,如数组。

当发现一个大对象在Eden区+Survior1区中存不下的时候就需要分配担保机制把当前Eden区+Survior1区的所有对象都复制到老年代中去。

我们知道,一个大对象能够存入Eden区+Survior1区的概率比较小,发生分配担保的概率比较大,而分配担保需要涉及到大量的复制,就会造成效率低下。

因此,对于大对象我们直接把他放到老年代中去,从而就能避免大量的复制操作。

那么,什么样的对象才是“大对象”呢?

通过-XX:PretrnureSizeThreshold参数设置大对象

该参数用于设置大小超过该参数的对象被认为是“大对象”,直接进入老年代。

注意:该参数只对Serial和ParNew收集器有效。

3.3 生命周期较长的对象进入老年代

老年代用于存储生命周期较长的对象,那么我们如何判断一个对象的年龄呢?

新生代中的每个对象都有一个年龄计数器,当新生代发生一次MinorGC后,存活下来的对象的年龄就加一,当年龄超过一定值时,就将超过该值的所有对象转移到老年代中去。

使用-XXMaxTenuringThreshold设置新生代的最大年龄

设置该参数后,只要超过该参数的新生代对象都会被转移到老年代中去。

3.4 相同年龄的对象内存超过Survior内存一半的对象进入老年代

如果当前新生代的Survior中,年龄相同的对象的内存空间总和超过了Survior内存空间的一半,那么所有年龄相同的对象和超过该年龄的对象都被转移到老年代中去。无需等到对象的年龄超过MaxTenuringThreshold才被转移到老年代中去。

3.5 “分配担保”策略详解

当垃圾收集器准备要在新生代发起一次MinorGC时,首先会检查“老年代中最大的连续空闲区域的大小 是否大于 新生代中所有对象的大小?”,也就是老年代中目前能够将新生代中所有对象全部装下?

若老年代能够装下新生代中所有的对象,那么此时进行MinorGC没有任何风险,然后就进行MinorGC。

若老年代无法装下新生代中所有的对象,那么此时进行MinorGC是有风险的,垃圾收集器会进行一次预测:根据以往MinorGC过后存活对象的平均数来预测这次MinorGC后存活对象的平均数。

如果以往存活对象的平均数小于当前老年代最大的连续空闲空间,那么就进行MinorGC,虽然此次MinorGC是有风险的。

如果以往存活对象的平均数大于当前老年代最大的连续空闲空间,那么就对老年代进行一次Full GC,通过清除老年代中废弃数据来扩大老年代空闲空间,以便给新生代作担保。

这个过程就是分配担保。

注意:

第四章 了解 Java 虚拟机的垃圾回收算法

Java虚拟机的内存模型分为五个部分,分别是:程序计数器、Java虚拟机栈、本地方法栈、堆、方法区。

这五个区域既然是存储空间,那么为了避免Java虚拟机在运行期间内存存满的情况,就必须得有一个垃圾收集者的角色,不定期地回收一些无效内存,以保障Java虚拟机能够健康地持续运行。

这个垃圾收集者就是平常我们所说的“垃圾收集器”,那么垃圾收集器在何时清扫内存?清扫哪些数据?这就是接下来我们要解决的问题。

程序计数器、Java虚拟机栈、本地方法栈都是线程私有的,也就是每条线程都拥有这三块区域,而且会随着线程的创建而创建,线程的结束而销毁。那么,垃圾收集器在何时清扫这三块区域的问题就解决了。

此外,Java虚拟机栈、本地方法栈中的栈帧会随着方法的开始而入栈,方法的结束而出栈,并且每个栈帧中的本地变量表都是在类被加载的时候就确定的。因此以上三个区域的垃圾收集工作具有确定性,垃圾收集器能够清楚地知道何时清扫这三块区域中的哪些数据。

然而,堆和方法区中的内存清理工作就没那么容易了。

堆和方法区所有线程共享,并且都在JVM启动时创建,一直得运行到JVM停止时。因此它们没办法根据线程的创建而创建、线程的结束而释放。

堆中存放JVM运行期间的所有对象,虽然每个对象的内存大小在加载该对象所属类的时候就确定了,但究竟创建多少个对象只有在程序运行期间才能确定。

方法区中存放类信息、静态成员变量、常量。类的加载是在程序运行过程中,当需要创建这个类的对象时才会加载这个类。因此,JVM究竟要加载多少个类也需要在程序运行期间确定。

因此,堆和方法区的内存回收具有不确定性,因此垃圾收集器在回收堆和方法区内存的时候花了一些心思。

4.1 堆内存的回收

4.1.1 如何判定哪些对象需要回收?

在对堆进行对象回收之前,首先要判断哪些是无效对象。我们知道,一个对象不被任何对象或变量引用,那么就是无效对象,需要被回收。一般有两种判别方式:

GC Roots是指:

  1. Java虚拟机栈所引用的对象(栈帧中局部变量表中引用类型的变量所引用的对象)

  2. 方法区中静态属性引用的对象

  3. 方法区中常量所引用的对象

  4. 本地方法栈所引用的对象

两者对比:

引用计数法虽然简单,但存在一个严重的问题,它无法解决循环引用的问题。

因此,目前主流语言均使用可达性分析方法来判断对象是否有效。

4.1.2 回收无效对象的过程

当JVM筛选出失效的对象之后,并不是立即清除,而是再给对象一次重生的机会,具体过程如下:

(1)判断该对象是否覆盖了finalize()方法

(2)执行F-Queue队列中的finalize()方法

虚拟机会以较低的优先级执行这些finalize()方法们,也不会确保所有的finalize()方法都会执行结束。如果finalize()方法中出现耗时操作,虚拟机就直接停止执行,将该对象清除。

(3)对象重生或死亡

如果在执行finalize()方法时,将this赋给了某一个引用,那么该对象就重生了。如果没有,那么就会被垃圾收集器清除。

注意:强烈不建议使用finalize()函数进行任何操作!如果需要释放资源,请使用try-finally。因为finalize()不确定性大,开销大,无法保证顺利执行。

4.2 方法区的内存回收

我们知道,如果使用复制算法实现堆的内存回收,堆就会被分为新生代和老年代,新生代中的对象“朝生夕死”,每次垃圾回收都会清除掉大量的对象;而老年代中的对象生命较长,每次垃圾回收只有少量的对象被清除掉。

由于方法区中存放生命周期较长的类信息、常量、静态变量,因此方法区就像是堆的老年代,每次垃圾收集的只有少量的垃圾被清除掉。

方法区中主要清除两种垃圾:

  1. 废弃常量

  2. 废弃的类

4.2.1 如何判定废弃常量?

清除废弃的常量和清除对象类似,只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。

4.2.2 如何废弃废弃的类?

清除废弃类的条件较为苛刻:

  1. 该类的所有对象都已被清除

  2. 该类的java.lang.Class对象没有被任何对象或变量引用

  3. 只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除。

  4. 加载该类的ClassLoader已经被回收

4.3 垃圾收集算法

现在我们知道了判定一个对象是无效对象、判定一个类是废弃类、判定一个常量是废弃常量的方法,也就是知道了垃圾收集器会清除哪些数据,那么接下来介绍如何清除这些数据。

4.3.1 标记-清除算法

首先利用刚才介绍的方法判断需要清除哪些数据,并给它们做上标记;然后清除被标记的数据。

分析:

这种算法标记和清除过程效率都很低,而且清除完后存在大量碎片空间,导致无法存储大对象,降低了空间利用率。

4.3.2 复制算法

将内存分成两份,只将数据存储在其中一块上。当需要回收垃圾时,也是首先标记出废弃的数据,然后将有用的数据复制到另一块内存上,最后将第一块内存全部清除。

分析:

这种算法避免了碎片空间,但内存被缩小了一半。

而且每次都需要将有用的数据全部复制到另一片内存上去,效率不高。

解决空间利用率问题:

在新生代中,由于大量的对象都是“朝生夕死”,也就是一次垃圾收集后只有少量对象存活,因此我们可以将内存划分成三块:Eden、Survior1、Survior2,内存大小分别是8:1:1。分配内存时,只使用Eden和一块Survior1。当发现Eden+Survior1的内存即将满时,JVM会发起一次MinorGC,清除掉废弃的对象,并将所有存活下来的对象复制到另一块Survior2中。那么,接下来就使用Survior2+Eden进行内存分配。

通过这种方式,只需要浪费10%的内存空间即可实现带有压缩功能的垃圾收集方法,避免了内存碎片的问题。

但是,当一个对象要申请内存空间时,发现Eden+Survior中剩下的空间无法放置该对象,此时需要进行Minor GC,如果MinorGC过后空闲出来的内存空间仍然无法放置该对象,那么此时就需要将对象转移到老年代中,这种方式叫做“分配担保”。

什么是分配担保?

当JVM准备为一个对象分配内存空间时,发现此时Eden+Survior中空闲的区域无法装下该对象,那么就会触发MinorGC,对该区域的废弃对象进行回收。但如果MinorGC过后只有少量对象被回收,仍然无法装下新对象,那么此时需要将Eden+Survior中的所有对象都转移到老年代中,然后再将新对象存入Eden区。这个过程就是“分配担保”。

4.3.3 标记-整理算法

在回收垃圾前,首先将所有废弃的对象做上标记,然后将所有未被标记的对象移到一边,最后清空另一边区域即可。

分析:

它是一种老年代的垃圾收集算法。老年代中的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,因此如果选用“复制”算法,每次需要复制大量存活的对象,会导致效率很低。而且,在新生代中使用“复制”算法,当Eden+Survior中都装不下某个对象时,可以使用老年代的内存进行“分配担保”,而如果在老年代使用该算法,那么在老年代中如果出现Eden+Survior装不下某个对象时,没有其他区域给他作分配担保。因此,老年代中一般使用“标记-整理”算法。

4.3.4 分代收集算法

将内存划分为老年代和新生代。老年代中存放寿命较长的对象,新生代中存放“朝生夕死”的对象。然后在不同的区域使用不同的垃圾收集算法。

4.4 Java中引用的种类

Java中根据生命周期的长短,将引用分为4类。

4.4.1 强引用

我们平时所使用的引用就是强引用。

A a = new A();

也就是通过关键字new创建的对象所关联的引用就是强引用。

只要强引用存在,该对象永远也不会被回收。

4.4.2 软引用

只有当堆即将发生OOM异常时,JVM才会回收软引用所指向的对象。

软引用通过SoftReference类实现。

软引用的生命周期比强引用短一些。

4.4.3 弱引用

只要垃圾收集器运行,软引用所指向的对象就会被回收。

弱引用通过WeakReference类实现。

弱引用的生命周期比软引用短。

4.4.4 虚引用

虚引用也叫幽灵引用,它和没有引用没有区别,无法通过虚引用访问对象的任何属性或函数。

一个对象关联虚引用唯一的作用就是在该对象被垃圾收集器回收之前会受到一条系统通知。

虚引用通过PhantomReference类来实现。


第五章 class 文件结构详解

5.1 什么是JVM的“无关性”?

Java具有平台无关性,也就是任何操作系统都能运行Java代码。之所以能实现这一点,是因为Java运行在虚拟机之上,不同的操作系统都拥有各自的Java虚拟机,因此Java能实现“一次编写,处处运行”。

而JVM不仅具有平台无关性,还具有语言无关性。

平台无关性是指不同操作系统都有各自的JVM,而语言无关性是指Java虚拟机能运行除Java以外的代码!

这听起来非常惊人,但JVM对能运行的语言是有严格要求的。首先来了解下Java代码的运行过程。

Java源代码首先需要使用Javac编译器编译成class文件,然后启动JVM执行class文件,从而程序开始运行。

也就是JVM只认识class文件,它并不管何种语言生成了class文件,只要class文件符合JVM的规范就能运行。

因此目前已经有Scala、JRuby、Jython等语言能够在JVM上运行。它们有各自的语法规则,不过它们的编译器都能将各自的源码编译成符合JVM规范的class文件,从而能够借助JVM运行它们。

5.2 纵观Class文件结构

class文件是二进制文件,它的内容具有严格的规范,文件中没有任何空格,全是连续的0/1。class文件中的所有内容被分为两种类型:无符号数 和 表。

5.2.1 class文件的组织结构

  1. 魔数

  2. 本文件的版本信息

  3. 常量池

  4. 访问标志

  5. 类索引

  6. 父类索引

  7. 接口索引集合

  8. 字段表集合

  9. 方法表集合

5.3 Class文件的构成1:魔数

class文件的头4个字节称为魔数,用来表示这个class文件的类型。

魔数的作用就相当于文件后缀名,只不过后缀名容易被修改,不安全,因此在class文件中标示文件类型比较合适。

class文件的魔数是用16进制表示的“CAFEBABE”,非常具有浪漫主义色彩,谁说程序员的情商都很低!

5.4 Class文件的构成2:版本信息

紧接着魔数的4个字节是版本号。它表示本class中使用的是哪个版本的JDK。

在高版本的JVM上能够运行低版本的class文件,但在低版本的JVM上无法运行高版本的class文件,即使该class文件中没有用到任何高版本JDK的特性也无法运行!

5.5 Class文件的构成3:常量池

5.5.1 什么是常量池?

紧接着版本号之后的就是常量池。常量池中存放两种类型的常量:

  1. 类和接口的全限定名

  2. 字段的名字 和 描述符

  3. 方法的名字 和 描述符

5.5.2 常量池的特点

注:这个值是从1开始的,若为5表示池中有4个常量。

5.5.3 常量池中常量的类型

刚才介绍了,常量池中的常量大体上分为:字面值常量 和 符号引用。在此基础上,根据常量的数据类型不同,又可以被细分为14种常量类型。这14种常量类型都有各自的二维表示结构。每种常量类型的头1个字节都是tag,用于表示当前常量属于14种类型中的哪一个。

以CONSTANT_Class_info常量为例,它的二维表示结构如下:

CONSTANT_Class_info表:

类型名称数量u1tag1u2name_index1

tag表示当前常量的类型(当前常量为CONSTANT_Class_info,因此tag的值应为7,表示一个类或接口的全限定名);

name_index表示这个类或接口全限定名的位置。它的值表示指向常量池的第几个常量。它会指向一个CONSTANT_Utf8_info类型的常量,它的二维表结构如下:

CONSTANT_Utf8_info表:

类型名称数量u1tag1u2length2u1byteslength

问:为什么Java中定义的类、变量名字必须小于64K?

类、接口、变量等名字都属于符号引用,它们都存储在常量池中。而不管哪种符号引用,它们的名字都由CONSTANT_Utf8_info类型的常量表示,这种类型的常量使用u2存储字符串的长度。由于2字节最多能表示65535个数,因此这些名字的最大长度最多只能是64K。

问:什么是UTF-8编码?什么是缩略UTF-8编码?

前者每个字符使用3个字节表示,而后者把128个ASKII码用1字节表示,某些字符用2字节表示,某些字符用3字节表示。

5.6 Class文件的构成4:访问标志

在常量池之后是2字节的访问标志。访问标志是用来表示这个class文件是类还是接口、是否被public修饰、是否被abstract修饰、是否被final修饰等。

由于这些标志都由是/否表示,因此可以用0/1表示。

访问标志为2字节,可以表示16位标志,但JVM目前只定义了8种,未定义的直接写0.

5.7 Class文件的构成5:类索引、父类索引、接口索引集合

类索引、父类索引、接口索引集合是用来表示当前class文件所表示类的名字、父类名字、接口们的名字。

它们按照顺序依次排列,类索引和父类索引各自使用一个u2类型的无符号常量,这个常量指向CONSTANT_Class_info类型的常量,该常量的bytes字段记录了本类、父类的全限定名。

由于一个类的接口可能有好多个,因此需要用一个集合来表示接口索引,它在类索引和父类索引之后。这个集合头两个字节表示接口索引集合的长度,接下来就是接口的名字索引。

5.8 Class文件的构成6:字段表的集合

5.8.1 什么是字段表集合?

接下来是字段表的集合。字段表集合用于存储本类所涉及到的成员变量,包括实例变量和类变量,但不包括方法中的局部变量。

每一个字段表只表示一个成员变量,本类中所有的成员变量构成了字段表集合。

5.8.2 字段表结构的定义

类型名称数量u2access_flags1u2name_index1u2descriptor_index1u2attributes_count1attribute_infoattributesattributes_count

5.8.3 什么是描述符?

成员变量(包括静态成员变量和实例变量)和 方法都有各自的描述符。

对于字段而言,描述符用于描述字段的数据类型;

对于方法而言,描述符用于描述字段的数据类型、参数列表、返回值。

在描述符中,基本数据类型用大写字母表示,对象类型用“L对象类型的全限定名”表示,数组用“[数组类型的全限定名”表示。

描述方法时,将参数根据上述规则放在()中,()右侧按照上述方法放置返回值。而且,参数之间无需任何符号。

5.8.4 字段表集合的注意点

  1. 一个class文件的字段表集合中不能出现从父类/接口继承而来字段;

  2. 一个class文件的字段表集合中可能会出现程序猿没有定义的字段

  3. 如编译器会自动地在内部类的class文件的字段表集合中添加外部类对象的成员变量,供内部类访问外部类。

  4. Java中只要两个字段名字相同就无法通过编译。但在JVM规范中,允许两个字段的名字相同但描述符不同的情况,并且认为它们是两个不同的字段。

5.9 Class文件的构成7:方法表的集合

在class文件中,所有的方法以二维表的形式存储,每张表来表示一个函数,一个类中的所有方法构成方法表的集合。

方法表的结构和字段表的结构一致,只不过访问标志和属性表集合的可选项有所不同。

类型名称数量u2access_flags1u2name_index1u2descriptor_index1u2attributes_count1attribute_infoattributesattributes_count

方法表的属性表集合中有一张Code属性表,用于存储当前方法经编译器编译过后的字节码指令。

方法表集合的注意点

  1. 如果本class没有重写父类的方法,那么本class文件的方法表集合中是不会出现父类/父接口的方法表;

  2. 本class的方法表集合可能出现程序猿没有定义的方法

  3. 编译器在编译时会在class文件的方法表集合中加入类构造器

  4. 和实例构造器。

  5. 重载一个方法需要有相同的简单名称和不同的特征签名。JVM的特征签名和Java的特征签名有所不同:


第六章 详解 Java 类的加载过程

6.1 类的生命周期

一个类从加载进内存到卸载出内存为止,一共经历7个阶段:

加载——>验证——>准备——>解析——>初始化——>使用——>卸载

其中,类加载包括5个阶段:

加载——>验证——>准备——>解析——>初始化

在类加载的过程中,以下3个过程称为连接:

验证——>准备——>解析

因此,JVM的类加载过程也可以概括为3个过程:

加载——>连接——>初始化

C/C++在运行前需要完成预处理、编译、汇编、链接;而在Java中,类加载(加载、连接、初始化)是在程序运行期间完成的。

在程序运行期间进行类加载会稍微增加程序的开销,但随之会带来更大的好处——提高程序的灵活性。Java语言的灵活性体现在它可以在运行期间动态扩展,所谓动态扩展就是在运行期间动态加载动态连接

6.2 类加载的时机

6.2.1 类加载过程中每个步骤的顺序

我们已经知道,类加载的过程包括:加载、连接、初始化,连接又分为:验证、准备、解析,所以说类加载一共分为5步:加载、验证、准备、解析、初始化。

其中加载、验证、准备、初始化的开始顺序是依次进行的,这些步骤开始之后的过程可能会有重叠。

而解析过程会发生在初始化过程中。

6.2.2 类加载过程中“初始化”开始的时机

JVM规范中只定义了类加载过程中初始化过程开始的时机,加载、连接过程都应该在初始化之前开始(解析除外),这些过程具体在何时开始,JVM规范并没有定义,不同的虚拟机可以根据具体的需求自定义。

初始化开始的时机:

  1. 在运行过程中遇到如下字节码指令时,如果类尚未初始化,那就要进行初始化:new、getstatic、putstatic、invokestatic。这四个指令对应的Java代码场景是:

  1. 使用java.lang.reflect进行反射调用的时候,如果类没有初始化,那就需要初始化;

  2. 当初始化一个类的时候,若其父类尚未初始化,那就先要让其父类初始化,然后再初始化本类;

  3. 当虚拟机启动时,虚拟机会首先初始化带有main方法的类,即主类;

6.2.3 主动引用 与 被动引用

JVM规范中要求在程序运行过程中,“当且仅当”出现上述4个条件之一的情况才会初始化一个类。如果间接满足上述初始化条件是不会初始化类的。

其中,直接满足上述初始化条件的情况叫做主动引用;间接满足上述初始化过程的情况叫做被动引用

那么,只有当程序在运行过程中满足主动引用的时候才会初始化一个类,若满足被动引用就不会初始化一个类。

6.2.4 被动引用的场景示例

示例一

public class Fu{
 public static String name = "柴毛毛";
 static{
 System.out.println("父类被初始化!");
 }
}
public class Zi{
 static{
 System.out.println("子类被初始化!");
 }
}
public static void main(String[] args){
 System.out.println(Zi.name);
}

输出结果:

父类被初始化!

柴毛毛

原因分析:

本示例看似满足初始化时机的第一条:当要获取某一个类的静态成员变量的时候如果该类尚未初始化,则对该类进行初始化。

但由于这个静态成员变量属于Fu类,Zi类只是间接调用Fu类中的静态成员变量,因此Zi类调用name属性属于间接引用,而Fu类调用name属性属于直接引用,由于JVM只初始化直接引用的类,因此只有Fu类被初始化。

示例二

public class A{
 public static void main(String[] args){
 Fu[] arr = new Fu[10];
 }
}

输出结果:

并没有输出“父类被初始化!”

原因分析:

这个过程看似满足初始化时机的第一条:遇到new创建对象时若类没被初始化,则初始化该类。

但现在通过new要创建的是一个数组对象,而非Fu类对象,因此也属于间接引用,不会初始化Fu类。

示例三

public class Fu{
 public static final String name = "柴毛毛";
 static{
 System.out.println("父类被初始化!");
 }
}
public class A{
 public static void main(String[] args){
 System.out.println(Fu.name);
 }
}

输出结果:

柴毛毛

原因分析:

本示例看似满足类初始化时机的第一个条件:获取一个类静态成员变量的时候若类尚未初始化则初始化类。

但是,Fu类的静态成员变量被final修饰,它已经是一个常量。被final修饰的常量在Java代码编译的过程中就会被放入它被引用的class文件的常量池中(这里是A的常量池)。所以程序在运行期间如果需要调用这个常量,直接去当前类的常量池中取,而不需要初始化这个类。

6.2.5 接口的初始化

接口和类都需要初始化,接口和类的初始化过程基本一样,不同点在于:类初始化时,如果发现父类尚未被初始化,则先要初始化父类,然后再初始化自己;但接口初始化时,并不要求父接口已经全部初始化,只有程序在运行过程中用到当父接口中的东西时才初始化父接口。

6.3 类加载的过程

通过之前的介绍可知,类加载过程共有5个步骤,分别是:加载、验证、准备、解析、初始化。其中,验证、准备、解析称为连接。下面详细介绍这5个过程JVM所做的工作。

6.3.1 加载

注意:“加载”是“类加载”过程的第一步,千万不要混淆。

在加载过程中,JVM主要做3件事情:

从哪里加载?

JVM规范对于加载过程给予了较大的宽松度。一般二进制字节流都从已经编译好的本地class文件中读取,此外还可以从以下地方读取:

类 和 数组加载过程的区别?

数组也有类型,称为“数组类型”。如:

String[] str = new String[10];


这个数组的数组类型是Ljava.lang.String,而String只是这个数组中元素的类型。

当程序在运行过程中遇到new关键字创建一个数组时,由JVM直接创建数组类,再由类加载器创建数组中的元素类。

而普通类的加载由类加载器完成。既可以使用系统提供的引导类加载器,也可以使用用户自定义的类加载器。

加载过程的注意点

  1. JVM规范并未给出类在方法区中存放的数据结构

  2. 类完成加载后,二进制字节流就以特定的数据结构存储在方法区中,但存储的数据结构是由虚拟机自己定义的,JVM规范并没有指定。

  3. JVM规范并没有指定Class对象存放的位置

  4. 在二进制字节流以特定格式存储在方法区后,JVM会创建一个java.lang.Class类型的对象,作为本类的外部接口。既然是对象就应该存放在堆内存中,不过JVM规范并没有给出限制,不同的虚拟机根据自己的需求存放这个对象。HotSpot将Class对象存放在方法区。

  5. 加载阶段和连接阶段是交叉的

  6. 通过之前的介绍可知,类加载过程中每个步骤的开始顺序都有严格限制,但每个步骤的结束顺序没有限制。也就是说,类加载过程中,必须按照如下顺序开始:

  7. 加载、连接、初始化,但结束顺序无所谓,因此由于每个步骤处理时间的长短不一就会导致有些步骤会出现交叉。

6.3.2 验证

验证阶段比较耗时,它非常重要但不一定必要,如果所运行的代码已经被反复使用和验证过,那么可以使用-Xverify:none参数关闭,以缩短类加载时间。

验证的目的是什么?

验证是为了保证二进制字节流中的信息符合虚拟机规范,并没有安全问题。

为什么需要验证?

虽然Java语言是一门安全的语言,它能确保程序猿无法访问数组边界以外的内存、避免让一个对象转换成任意类型、避免跳转到不存在的代码行,如果出现这些情况,编译无法通过。也就是说,Java语言的安全性是通过编译器来保证的。

但是我们知道,编译器和虚拟机是两个独立的东西,虚拟机只认二进制字节流,它不会管所获得的二进制字节流是哪来的,当然,如果是编译器给它的,那么就相对安全,但如果是从其它途径获得的,那么无法确保该二进制字节流是安全的。通过上文可知,虚拟机规范中没有限制二进制字节流的来源,那么任意来源的二进制字节流虚拟机都能接受,为了防止字节流中有安全问题,因此需要验证!

验证的过程

(1)文件格式验证

这个阶段主要验证输入的二进制字节流是否符合class文件结构的规范。二进制字节流只有通过了本阶段的验证,才会被允许存入到方法区中。

本验证阶段是基于二进制字节流的,而后面的三个验证阶段都是在方法区中进行,并基于类特定的数据结构的。

通过上文可知,加载开始前,二进制字节流还没进方法区,而加载完成后,二进制字节流已经存入方法区。而在文件格式验证前,二进制字节流尚未进入方法区,文件格式验证通过之后才进入方法区。也就是说,加载开始后,立即启动了文件格式验证,本阶段验证通过后,二进制字节流被转换成特定数据结构存储至方法区中,继而开始下阶段的验证和创建Class对象等操作。这个过程印证了:加载和验证是交叉进行的。

(2)元数据验证

本阶段对方法区中的字节码描述信息进行语义分析,确保其符合Java语法规范。

(3)字节码验证

本阶段是验证过程的最复杂的一个阶段。本阶段对方法体进行语义分析,保证方法在运行时不会出现危害虚拟机的事件。

(4)符号引用验证,本阶段验证发生在解析阶段,确保解析能正常执行。

6.3.3 准备

准备阶段完成两件事情:

  1. 为已经在方法区中的类中的静态成员变量分配内存

  2. 类的静态成员变量也存储在方法区中。

  3. 为静态成员变量设置初始值

  4. 初始值为0、false、null等。

示例1:

public static String name = "柴毛毛";


在准备阶段,JVM会在方法区中为name分配内存空间,并赋上初始值null。

给name赋上"柴毛毛"是在初始化阶段完成的。

示例2:

public static final String name = "柴毛毛";


被final修饰的常量如果有初始值,那么在编译阶段就会将初始值存入constantValue属性中,在准备阶段就将constantValue的值赋给该字段。

6.3.3 解析

解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。

6.3.4 初始化

初始化阶段就是执行类构造器clinit()的过程。

clinit()方法由编译器自动产生,收集类中static{}代码块中的类变量赋值语句和类中静态成员变量的赋值语句。在准备阶段,类中静态成员变量已经完成了默认初始化,而在初始化阶段,clinit()方法对静态成员变量进行显示初始化。

初始化过程的注意点:

6.4 类加载器

6.4.1 类与类加载器

6.4.2 类加载器种类

JVM提供如下三种类加载器:

6.4.3 双亲委派模型


第七章 Java 虚拟机的锁优化策略

7.1 自旋锁

7.2 锁清除

编译器会清除一些使用了同步,但同步块中没有涉及共享数据的锁,从而减少多余的同步。

7.3 锁粗化

若有一系列操作,反复地对同一把锁进行上锁和解锁操作,编译器会扩大这部分代码的同步块的边界,从而只使用一次上锁和解锁操作。

7.4 轻量级锁

7.5 偏向锁


小编在学习过程中整理了一些学习资料,可以分享给做java的工程师朋友们,相互交流学习,需要的可以加入我的学习交流群 778477315 即可免费获取Java架构学习资料(里面有高可用、高并发、高性能及分布式、Jvm性能调优、Spring源码,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多个知识点的架构资料)

其中覆盖了互联网的方方面面,期间碰到各种产品各种场景下的各种问题,很值得大家借鉴和学习,扩展自己的技术广度和知识面。最后记得帮作者点个关注


推荐阅读:
  1. 深入浅出的JVM
  2. 大白话带你深入浅出JVM

免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。

java虚拟机 架构师 java

上一篇:MongoDB数据库适用于哪些场景

下一篇:DB2 HADR性能分析

相关阅读

您好,登录后才能下订单哦!

密码登录
登录注册
其他方式登录
点击 登录注册 即表示同意《亿速云用户服务条款》