【JVM系列】读懂Java虚拟机(JVM)这一篇就够了!

197 阅读1小时+

在这里插入图片描述

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机本质上就是一个程序,当它在命令行上启动的时候,就开始执行保存在某字节码文件中的指令。Java语言的可移植性正是建立在Java虚拟机的基础上。任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class)就可以在该平台上运行。这就是“一次编译,多次运行”。

对于JAVA程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作都写配对的delete/free代码(释放内存操作),不容易出现内存泄漏和内存溢出问题,但是并不是代表不会出现,一旦出现这方面问题我们需要知道如何去解决问题。

本文将从内存管理、垃圾回收、类文件结构、类加载机制、字节码执行引擎、以及调优实例来让你学习JVM!

内存管理

1、运行时数据区域

     JAVA虚拟机在执行Java程序的过程中会把它所管理的内存区域划分为若干个不同的区域来完成各自的作用,运行时数据区主要分为堆、虚拟机栈、本地方法栈、方法区以及程序计数器这些,如下图所示:

在这里插入图片描述

1.1 堆

     对于绝大多数应用来说,这块区域是 JVM 所管理的内存中最大的一块。线程共享,主要是存放对象实例和数组。内部会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。可以位于物理上不连续的空间,但是逻辑上要连续。

     JAVA堆是垃圾回收的主要区域,从内存回收的角度Java堆可以细分为:新生代和老年代,再细一点可以分为Eden、From Survivor空间和To Survivor空间等。无论如何划分存储的都是对象的实例,细分的目的是为了更好的管理内存,更好的进行回收内存。如果堆内没有内存完成实例分配,并且堆无法进行扩展的时候(主流虚拟机通过-Xmx和-Xms控制),将会抛出OutOfMemoryError异常。

在这里插入图片描述

1.2 虚拟机栈

     线程私有,生命周期和线程一样。虚拟机栈描述的是JAVA方法执行的内存模型:每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法的执行就对应这一个栈帧在虚拟机栈中的入栈到出栈的过程。

在这里插入图片描述

     其中,局部变量表存放了编译器可知的基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型)和 returnAddress 类型(指向了一条字节码指令的地址)。对象引用类型不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象有关的位置(和堆空间为new对象的分配方法有关,而分配方法又和垃圾回收器有关,下面会详细介绍)。

     这个区域存在两种异常,若线程请求的栈深度大于虚拟机所允许的深度,则抛出StackOverflowError;若虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存,则抛出OutOfMemoryError异常。

1.3 本地方法栈

     区别于 Java 虚拟机栈的是,Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。也会有 StackOverflowError 和 OutOfMemoryError 异常。

1.4 方法区栈

     方法区也是属于各个线程共享的内存区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

     Java虚拟机规范堆方法区的限制很宽松,除了和堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以不实现垃圾收集。垃圾收集行为在这个区域是比较少出现的,这个区域的内存回收目标主要是针对常量池的回收和类型的卸载。这部分无法满足内存分配需求时,将抛出OutOfMemoryError 异常。

运行时常量池

     运行时常量池属于方法区一部分,用于存放编译期生成的class文件的常量池中的各种字面量和符号引用。

     Java虚拟机对Class文件中每一部分都有严格的规定,Class文件格式固定,下面会讲解。但对于运行时常量池来说,jvm虚拟机未做任何细节的要求,除了Class文件中描述的符号引用外,还会把翻译出来的直接引用存储在运行时常量池中。

     运行时常量池相对于Class文件内部的常量池的一个重要特性就是具备动态性,JAVA语言并不要求常量必须在编译器才能产生,也就是并非放入Class文件常量池的内容才能进入方法区的运行时常量池,运行期间也可以将新的常量放入池中。编译器和运行期(String 的 intern() )都可以将常量放入池中。内存有限,无法申请时抛出 OutOfMemoryError。

1.5 程序计数器

程序计数器是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

(1)区别于计算机硬件的pc寄存器,两者不略有不同。计算机用pc寄存器来存放“伪指令”或地址,而相对于虚拟机,pc寄存器它表现为一块内存(一个字长,虚拟机要求字长最小为32位),虚拟机的pc寄存器的功能也是存放伪指令,更确切的说存放的是将要执行指令的地址。

(2)当虚拟机正在执行的方法是一个本地(native)方法的时候,jvm的pc寄存器存储的值是undefined。

(3)程序计数器是线程私有的,它的生命周期与线程相同,每个线程都有一个。

(4)此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

1.6 直接内存

     非虚拟机运行时数据区的部分,在 JDK 1.4 中新加入 NIO (New Input/Output) 类,引入了一种基于通道(Channel)和缓存(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。可以避免在 Java 堆和 Native 堆中来回的数据耗时操作。

OutOfMemoryError:会受到本机内存限制,如果内存区域总和大于物理内存限制从而导致动态扩展时出现该异常。

2、Java对象

2.1 对象的创建

在这里插入图片描述

     当虚拟机遇到new指令的时候,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,执行相应的类加载。(类的加载过程会在下面讲解)

     类加载检查通过之后,为新对象分配内存(内存大小在类加载完成后便可确认)。接下来就是在堆中划分出一块同样大小的内存出来,这里的划分方法分为两种,一种是“指针碰撞”,另一种是“空闲列表”。

  • 指针碰撞:若Java堆得内存是绝对规整的,所有用过的内存放在一边,空闲的内存放在另一边,中间放着一个分界点的指示器,只需要把指针向一边挪动一块和对象内存大小相等的内存即可。
  • 空闲列表:Java堆内存不是绝对规整,已使用的内存和空闲内存互相交错,虚拟机维护着一个列表,记录着哪些内存可用,在分配内存的时候在列表中找出一块足够大的内存划分给此对象,并更新列表上的记录。

     选择哪种方式是由虚拟机内存是否规整决定的,而数据内存是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定的。在使用Serial、ParNew等带Compact过程的收集器的时候,系统采用的是指针碰撞分配内存。而使用CMS这种基于Mark-Sweep算法的收集器,通常采用空闲列表。

     这里还存在一个问题,对象创建在虚拟机中是非常频繁的行为,在并发情况下修改指针的行为并不是线程安全的,如何解决?

     在并发情况下,可能出现正在给A分配内存,指针还未来得及修改,对象B又同时使用了原来的指针来分配内存的情况。有两种方案解决这个问题:

  • 一种是对分配内存的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
  • 另一种是把内存分配的动作划分在不同的空间之中进行,即每个线程都会在Java堆中预先分配一小块内存(本地线程分配缓冲TLAB)。当TLAB使用完再使用同步处理分配内存,虚拟机是否采用TLAB,可通过-XX:+/-UseTLAB参数设定。

     截止到这里,内存分配完成,之后需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在Java代码中可以不赋初始值直接使用,程序就可以访问到这些字段的数据类型所对应的零值。

     接下来虚拟机需要为对象的对象头进行必要的设置,包括这个对象属于哪个类的实例、如何才能找到类的元数据信息、对象的哈希吗和分代年龄等信息,设置完成之后在虚拟机的角度一个新的对象便已经产生了,但是从程序的视角对象的创建才刚刚开始,init方法执行,按照程序员的意愿进行初始化之后,一个可用的对象完成。

2.2 对象的内存布局

在 HotSpot 虚拟机中,分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

1、对象头:分为存储对象自身的运行时数据和类型指针两部分。第一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,32 位虚拟机占 32 bit,64 位虚拟机占 64 bit。官方称为 ‘Mark Word’。

在这里插入图片描述

第二部分是类型指针,即对象指向它的类的元数据指针,虚拟机通过这个指针确定这个对象是哪个类的实例。另外,如果是 Java 数组,对象头中还必须有一块用于记录数组长度的数据,因为普通对象可以通过 Java 对象元数据确定大小,而数组对象不可以。

2、实例数据:对象真正存储的有效数据(包括父类继承下来的和子类定义的),都需要记录下来。 3、对其填充:不是必然存在,无特别含义,仅仅起到占位符的作用。虚拟机自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。而对象头部分刚好是8字节的倍数,所以当对象实例数据没有对齐时,需要通过对齐来填充。

2.3 对象的访问定位

   我们的Java程序需要通过栈上的reference引用数据来操作堆上的具体对象。

      由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用该如何去定位、访问堆中对象的具体位置。所以对象访问方式也是取决于虚拟机实现而定的,目前主流得有使用句柄和直接指针两种方式。

  • 使用句柄:如下图所示,Java堆中划分出一块内存叫做句柄池,栈帧中存放的reference引用指向这个句柄池地址;句柄池中存放两类指针,一个是指向实例数据,实例数据位于堆中的其它区域。一个是指向对象类型,位于方法区中(方法区中存放的是类型信息啊)。 在这里插入图片描述
  • 直接指针:如下图所示,栈帧中的reference引用直接指向Java堆中的实例数据,对象的对象头中的类型指针便指向方法区中的对象类型数据。 在这里插入图片描述

     使用句柄的最大好处是 reference 中存储的是稳定的句柄地址,在对象移动(GC时较多)是只改变实例数据指针地址,reference 自身不需要修改。      直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销。如果是对象频繁 GC 那么句柄方法好,如果是对象频繁访问则直接指针访问好。

垃圾回收和内存分配

     程序计数器、虚拟机栈、本地方法栈 3 个区域随线程生灭(因为是线程私有),栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期才知道那些对象会创建,这部分内存的分配和回收都是动态的,垃圾回收期所关注的就是这部分内存。

3.1 对象已死吗?

   在进行内存回收之前要做的事情就是判断那些对象是‘死’的(即不可能再被引用的对象),哪些是‘活’的。可分为引用计数算法和可达性分析算法两种。

引用计数算法

       给对象添加一个引用计数器,每当有一个地方引用它,计数器就加一;当引用失效的时候,计数器值就减一;任何时刻计数器为0的对象是不可能再被使用的。引用计数算法的实现简单,判定效率也高,在大部分情况还是不错的算法。但是,它很难解决对象之间相互循环引用的问题。 在这里插入图片描述        如上图所示,在线程的共享区域的堆中,TestA的实例b引用TestB的实例a,TestB的实例a引用反过来又引用TestA的实例b,如果此时直接把a和b分别置为null(此时栈帧空间的a和b变为null),但是在堆空间中的对象却是互相引用的关系,导致它们的引用计数器不为0,于是引用计数器算法无法通知GC收集器回收它们。

可达性分析算法

       在主流商用语言的主流实现中,都是称通过可达性分析来判定对象是否存活的。这个算法的基本思想是通过一系列的称为“GC Roots”的对象作为起始点,从这些起始点所走过的路径称为引用链。当一个对象到“GCRoots”无任何引用链相连的时候,则证明此对象是不可用的。如下图所示,虽然object5、object6、object7之间互相有关联,但是GC Roots是不可达的,所以会被判定为可回收对象,也就解决了引用计数法中存在的问题。 在这里插入图片描述 可作为GC roots的对象包括下面几种:

  • 虚拟机栈(栈帧的本地变量表)中引用的对象。
  • 方法区中的类静态属性引用的对象。
  • 方法区中的常量引用的对象。
  • 本地方法栈中JNI(也就是Native方法)引用的对象。

补充      即使在可达性分析算法中不可达的对象,也并非是非死不可的,也就是还有存活的机会,这个时候它们暂时处于“缓刑”阶段。一个对象的真正死亡至少要经过两次标记过程:如果对象经过可达性分析之后发现没有和GC Roots有关的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是是否有必要执行finalize()方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。       如果这个对象被判定为有必要执行finalize()方法,那么这个对象会被放在一个F-Queue的队列之中,并稍后虚拟机会为其建立一个低优先级的 Finalizer 线程去执行它(切记:这里的执行只是是触发这个方法,并不承诺会等待它运行结束)。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己 —— 只要重新与引用链上的任何一个对象建立关联即可。finalize() 方法只会被系统自动调用一次。

四种引用

     前面的两种方式判断存活都和reference引用有关。如果我们希望描述这样一类对象:当内存空间足够的时候,则保留在内存中;如果内存空间在进行垃圾回收之后还是不够用,则可以抛弃这些对象。在JDK1.2之后,Java将引用分为强引用、软引用、弱引用、虚引用四种,这四种引用强度依次减弱。

  • 强引用:类似于 Object obj = new Object(); 创建的,只要强引用在垃圾回收器就不回收。
  • 软引用:用于描述一些还有用但非必需的对象。用SoftReference 类实现软引用,在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。若回收完还是没有足够的内存才会抛出内存溢出正常。
  • 弱引用:用来描述非必需对象的,强度比软引用更弱一些。用WeakReference 类实现弱引用,对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。
  • 虚引用:幽灵引用或者幻影引用(很魔幻的名称)。它是一种最弱的引用关系,一个对象的虚引用不会对生存时间构成影响、也无法通过虚引用来取得对象实例。用PhantomReference 类实现虚引用,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

回收方法区

     很多人认为方法区(或者虚拟机中的永久代)是没有垃圾收集的,jvm虚拟机确实未要求虚拟机在方法区实现垃圾回收,而且在方法区进行垃圾回收性价比一般很低。在堆中,尤其是在新生代中,一次垃圾回收一般可以回收 70% ~ 95% 的空间,而方法区的垃圾收集效率远低于此。 永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量和回收Java堆中的对象类似,判断是否还存在引用。而要判定一个类是否是“无用的类”的条件则比较苛刻,需要满足下面三个条件:

  • 该类的所有实例都已经回收,也就是Java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.class对象没有地方引用,也就是没有地方调用该类的类型对象,无法在任何地方通过反射访问该类的方法。

     满足上面三个条件的无用类可以进行回收,这里仅仅说的是可以,不代表一定会回收。是否对类进行回收,jvm提供了-Xnoclassgc参数进行控制。

3.2 垃圾回收算法

3.2.1 几种算法思想

     接下来将简单介绍标记-清除算法、复制算法、标记-整理算法以及分代收集算法等;

  • 标记-清除算法:分为标记和清除两个阶段,首先标记处所以需要回收的对象,在标记完成之后进行统一回收所有被标记的对象。(是不是联想到上面的新建对象内存分配那里,2种方法,指针碰撞和空闲列表,这个收集算法只能用于空闲列表) 在这里插入图片描述 两个不足:一是效率问题,标记和清除的两个过程的效率都不高;另一个是空间问题,标记和清除之后会产生大量不连续的内存碎片,空间碎片较多导致如果需要分配较大对象时,无法找到足够的连续内存而不得不触发另一次的垃圾收集动作。

  • 复制算法: 为了解决上述的效率问题,复制算法出现了。复制算法是把空间分成两块,每次只对其中一块进行 GC。当这块内存使用完时,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。 在这里插入图片描述 解决了前一种方法的效率问题,但是会存在空间利用率低的问题。因为大多数新生代对象都不会熬过第一次 GC。所以没必要 1 : 1 划分空间。可以分一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 上,最后清理 Eden 和 Survivor 空间。大小比例一般是 8 : 1 : 1,每次浪费 10% 的 Survivor 空间。

这时出现一个问题:我们无法保证每次回收都只有不到10%的对象存活,怎么办?

     这里采用的是依靠其它内存(这里指老年代)内存的分配担保,内存分配担保就是当Survivor空间不够用的时候,剩余的对象将会直接通过分配担保机制进入老年代。

  • 标记-整理算法:复制收集算法适用于存活率较低的情况,一旦存活率较高,效率将会变低。老年代的存活率很高,于是有了标记-整理算法。标记整理算法的标记过程和标记清除算法一样,但是后续步骤不再是直接对可回收对象进行清理,而是让所有存活的对象都向一边移动,然后直接清理掉边界以外的内存。 在这里插入图片描述

  • 分代收集算法:根据存活对象划分几块内存区,一般是分为新生代和老年代。然后根据各个年代的特点制定相应的回收算法。对于新生代来说,每次垃圾回收都有大量对象死去,只有少量存活,选用复制算法比较合理。对于老年代来说,老年代中对象存活率较高、没有额外的空间分配对它进行担保。所以必须使用 标记 —— 清除 或者 标记 —— 整理 算法回收。

3.2.2 HotSpot算法实现

     在判断对象是否“死亡”的可达性分析方法中,是需要有GC Roots的,可作为GC Roots的节点主要在全局性引用(方法区中常量或者静态属性)和执行上下文中(例如栈帧中的局部变量表)中,方法区有时会很大,数百兆,如果要检查这里面的引用必然会消耗很多时间。

     可达性分析对执行时间的敏感还体现在GC停顿上,因为这里的工作必须在一个可以确保一致性的快照中进行,一致性的意思就是整个分析期间整个执行系统像被冻结,不能出现在分析过程中对象的引用关系还在不断的变化的情况,如果该点不满足,分析结果的准确性便无法得到保障。所以我们不可能花大量时间去扫描方法区,那么虚拟机如何做到不去扫描方法区找到可作为GC Roots的对象的呢?

枚举根节点

     在HotSpot的实现枚举根节点中,是使用一组称为OopMap的数据结构来达到,让虚拟机直接得知哪些地方存放着对象引用的。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。

  • 个人理解(标记,有待更正,有时间会请教专业JVM开发人员再来 修改):      OopMap结构用于枚举GC Roots,RememberedSet 用于可达性分析。垃圾收集的时候,收集线程会对栈上的内存进行扫描,看看哪些位置存储了Reference类型并且记录下来不可被回收,但问题是,栈上的本地变量表里面只有一部分数据是 Reference 类型的(它们是我们所需要的),那些非 Reference 类型的数据对我们而言毫无用处,但我们还是不得不对整个栈全部扫描一遍,这是对时间和资源的一种浪费。

     一个很自然的想法就是用空间换时间,把栈上代表引用的位置全记录下来,这样下次GC的时候便可以直接读取,不需要全局扫描判断了。HotSpot ,它使用一种叫做 OopMap 的数据结构来记录这类信息。我们知道一个线程一个栈,一个栈对应多个栈帧(每个栈帧对应一个方法),一个方法里面可能有多个安全点(安全点下面介绍),GC的时候程序会找到最近的安全点停下来,并且更新自己的OopMap,记录下哪些位置代表着引用。枚举根节点时,遍历每个栈帧的OopMap,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots )。

     我们可以很清楚的看到使用 OopMap 可以避免全栈扫描,加快枚举根节点的速度。但这并不是它的全部用意。它的另外一个更根本的作用是,可以帮助 HotSpot 实现准确式 GC 。关于准确式 GC 的具体内容(如:什么叫准确式 GC ?什么叫保守式 GC ?什么叫半保守式 GC ?准确式 GC 有哪些实现思路?等等)点击找出栈上的指针/引用 这篇R大的文章。

     RememberedSet:比如说,新生代 gc (它发生得非常频繁)。一般来说, gc 过程是这样的:首先枚举根节点。根节点有可能在新生代中,也有可能在老年代中。这里由于我们只想收集新生代(换句话说,不想收集老年代),所以没有必要对位于老年代的 GC Roots 做全面的可达性分析。但问题是,确实可能存在位于老年代的某个 GC Root,它引用了新生代的某个对象,这个对象你是不能清除的。那怎么办呢?

     仍然是拿空间换时间的办法。事实上,对于位于不同年代对象之间的引用关系,虚拟机会在程序运行过程中给记录下来。对应上面所举的例子,“老年代对象引用新生代对象”这种关系,会在引用关系发生时,在新生代边上专门开辟一块空间记录下来,这就是 RememberedSet 。所以“新生代的 GC Roots ” + “ RememberedSet 存储的内容”,才是新生代收集时真正的 GC Roots 。然后就可以以此为据,在新生代上做可达性分析,进行垃圾回收。

     我们知道, G1 收集器使用的是化整为零的思想,把一块大的内存划分成很多个域( Region )。但问题是,难免有一个 Region 中的对象引用另一个 Region 中对象的情况。为了达到可以以 Region 为单位进行垃圾回收的目的, G1 收集器也使用了 RememberedSet 这种技术,在各个 Region 上记录自家的对象被外面对象引用的情况。

安全点

     我们现在了解了OopMap如何帮助我们快速的完成GC Roots的枚举,这里边存在一个问题:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。

     HotSpot选择不为每个指令都生成OopMap,而只在特定的位置记录这些信息,这些位置便被称为“安全点Safepoint”。也就是说程序并非在所有地方停下来开始GC,只有在达到安全点更新了OopMap之后才能暂停进行GC。

     Safepoint的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基本上是以程序是否具有让程序长时间执行的特征为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,长时间执行的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。

     对于Safepoint,一个需要考虑的问题是如何让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点再停顿下来?两种方案抢先式中断和主动式中断;

  1. 抢先式中断:不需要线程的执行代码配合,GC时直接把所有线程全部中断,如果发现有的线程不在安全点上,就恢复线程,让它运行到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。

  2. 主动式中断:需要线程的代码配合,GC需要中断线程时,不直接对线程操作,而是设置一个标志,各个线程执行时去轮训这个标志,发现中断标志为真时就自己中断挂起。轮训标志的地方和安全点是重合的,==另外再加上创建对象需要分配内存的地方==。

安全区域

     Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候(如线程处于Sleep状态或Blocked状态),这时线程无法响应JVM的中断请求,无法运行到安全的地方去中断挂起,这时候就需要安全区域(Safe Region)来解决。

     安全区域指的是在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。

     在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。

3.3 垃圾收集器

介绍垃圾收集器之前先介绍几个概念:

  • 并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
  • 并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。
  • 吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%

     收集算法是内存回收的理论,而垃圾回收器是内存回收的实践。如下图所示,如果两个收集器之间存在连线说明他们之间可以搭配使用。虚拟机所在的区域则表示它是属于新生代收集器还是老年代收集器。

在这里插入图片描述

3.3.1 Serial新生代收集器

在这里插入图片描述

     Serial收集器是最基本、发展历史最悠久的收集器,曾是(JDK1.3.1之前)虚拟机新生代收集的唯一选择。Serial是一个单线程收集器,它只会使用一个CPU或一条收集器线程来完成垃圾收集工作,更重要的是它在垃圾收集的时候,必须暂停掉其它所有的工作线程,直到它结束为止(Stop The World)。

     它是HotSpot虚拟机运行在Client模式下的默认的新生代收集器。它也有着优于其他收集器的地方: 简单而高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率。可以使用"-XX:+UseSerialGC"参数来显式的使用Serial垃圾收集器。

3.3.2 ParNew新生代收集器

在这里插入图片描述

     ParNew收集器就是Serial收集器的多线程版本,它也是一个 新生代收集器。除了使用多线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与Serial收集器完全相同,两者共用了相当多的代码。

     ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为它是除了Serial收集器外,唯一一个能与CMS收集器配合工作的(CMS稍后介绍)。

     ParNew 收集器在 单CPU的环境中绝对不会有比Serial收集器有更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越。在 多CPU环境下,随着CPU的数量增加,它对于GC时系统资源的有效利用是很有好处的。它默认开启的收集线程数与CPU的数量相同,在CPU非常多的情况下可使用 -XX:ParallerGCThreads参数设置。

3.3.3 ParNew Scanvenge收集器

     Parallel Scavenge收集器也是一个 并行的 多线程新生代收集器,它也使用 复制算法。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是 达到一个可控制的吞吐量(Throughput)。

     停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而 高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合 在后台运算而不需要太多交互的任务。Parallel Scavenge收集器提供了两个参数来用于精确控制吞吐量,一是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis参数,二是控制吞吐量大小的 -XX:GCTimeRatio参数。

自适应调节策略:

     Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

3.3.4 Serial Old收集器

在这里插入图片描述

     Serial Old 是 Serial收集器的老年代版本,它同样是一个 单线程收集器,使用 “标记-整理”(Mark-Compact)算法。此收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:

  • 在JDK1.5 以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。
  • 作为CMS收集器的后备预案,在并发收集发生 Concurrent Mode Failure时使用。

3.3.5 Parallel Old收集器

在这里插入图片描述

     Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用 多线程和 “标记-整理”算法。前面已经提到过,这个收集器是在JDK 1.6中才开始提供的,在此之前,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old以外别无选择,所以在Parallel Old诞生以后, “吞吐量优先”收集器终于有了比较名副其实的应用组合,在 注重吞吐量以及 CPU资源敏感的场合,都可以优先考虑Parallel Scavenge(复制算法)加Parallel Old(标记-整理算法)收集器。


3.3.6 CMS收集器

     CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度。从名字上(“Mark Sweep”)就可以看出它是基于 “标记-清除”算法实现的。

在这里插入图片描述

CMS收集器工作流程:

  1. 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”;
  2. 并发标记:从GC Roots 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但 可与用户程序并发执行;
  3. 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”;
  4. 并发清除:清除掉“死亡”对象。

解析:初始标记和并发标记是为了找出所有的存活对象,重新标记是为了修正这期间的变化,重新标记期间会“Stop The World”,停掉所有用户线程,这样保证收集器的准确性;

     由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

缺点:

  • 对CPU资源敏感:其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。 CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是 当CPU不足4个时(比如2个),CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。
  • 无法处理浮动垃圾:可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。 由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就被称为 “浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
  • 基于“标记-清除”产生内存碎片:CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大.麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。

3.3.7 G1收集器

在这里插入图片描述 一款面向服务端应用的垃圾收集器。与其他GC收集器相比,G1具备如下特点:

  • 并行与并发:G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“Stop The World”停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。

  • 分代收集:G1能够独自管理整个Java堆,并且采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。

  • 空间整合:G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。

  • 可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为M毫秒的时间段内,消耗在垃圾收集上的时间不得超过N毫秒。

     在G1之前的其他收集器进行收集的范围都是整个新生代或者老生代,而G1不再是这样。G1在使用时,Java堆的内存布局与其他收集器有很大区别,它 将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但 新生代和老年代不再是物理隔离的了,而都是一部分Region(不需要连续)的集合。

     G1收集器为何能建立可预测的停顿时间模型?

     因为它有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这样就保证了在有限的时间内可以获取尽可能高的收集效率。

     如何避免全堆扫描?-- Remembered Set

     G1把Java堆分为多个Region,就是把内存“化整为零”的思路。但是Region不可能是孤立的,一个对象分配在某个Region中,可以与整个Java堆任意的对象发生引用关系。在用可达性分析确定对象是否存活的时候,需要扫描整个Java堆才能保证准确性,这显示是对GC效率的极大伤害。      为了避免全堆扫描,虚拟机为每一个Region维护了一个与之对应的Remembered Set(看上面算法实现枚举根节点部分)。对于位于不同年代对象之间的引用关系,虚拟机会在程序运行过程中给记录下来。对于“老年代对象引用新生代对象”这种关系,会在引用关系发生时,在新生代边上专门开辟一块空间记录下来并保存到Set中,所以“新生代的 GC Roots ” + “ RememberedSet 存储的内容”,才是新生代收集时真正的 GC Roots 。然后以此来进行可达性分析,垃圾回收。

     G1收集器运作步骤(不考虑维护Remembered Set的操作)?

  • 初始标记:标记一下GC Roots 能直接关联到的对象,修改 TAMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以的Region中创建对象,需要Stop The World;
  • 并发标记:从GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但 可与用户程序并发执行;
  • 最终标记:在并发标记期间因用户程序运作而导致标记产生变动的记录,虚拟机将变化记录在线程的Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要Stop The World,但是可并行执行。
  • 筛选回收:首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

     总结版:

| 垃圾收集器 | 新生代/老年代 | 串行/并行/并发| 垃圾收集算法 |特点| |:--------:|:-------------:|:-------------:|:-------------:|:-------------:|:-------------:| | Serial收集器 | 新生代 |串行|复制算法|单线程、简单而高效、停顿时间长 | ParNew | 新生代 |并行|复制算法|多线程、只有它能和CMS收集器配合工作 | Parallel Scavenge | 新生代|并行| 复制算法|多线程、吞吐量优先(适用于后台运算不需要太多交互的场景) | Serial Old(MSC) | 老年代 |串行|标记-整理|单线程、Serial收集器的老年代版本 | Parallel Old | 老年代 |并行|标记-整理|多线程、Parallel Scavenge的老年代版本,吞吐量优先 | CMS | 老年代 |并发|标记-清除|以获取最短回收停顿时间为目标、注重响应速度 | G1 | 分代收集 |并发|标记-整理和复制| 面向服务端应用的垃圾收集器,可预测的停顿时间

3.4 内存分配和回收策略

     Java自动内存管理归结为解决了两个问题:给对象分配内存和回收分配给对象的内存;关于回收分配给对象的内存我们介绍了如何判断对象“死亡”、垃圾回收算法以及垃圾收集器等运作原理。关于如何给对象分配内存我们简单介绍了创建对象的过程,但是我们还未讲解创建的对象具体存放的位置这一话题,接下来讲的就是关于内存分配的原理。      对象的内存分配,从大方向讲就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接在栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配,少数情况下也可能直接分配在老年代中,当然分配的规则并不是固定的,其细节取决于使用的是哪一种收集器组合,还有虚拟机中与内存相关的参数的设置。垃圾收集器组合一般就是Serial+Serial Old和Parallel+Serial Old,前者是Client模式下的默认垃圾收集器组合,后者是Server模式下的默认垃圾收集器组合。 在这里插入图片描述

  • 新生代Minor GC:指发生在新生代的垃圾收集动作,因为Java对象大多数都具有朝生夕灭的特性,多以Minor GC非常频繁,一般回收速度也比较快。
  • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常都伴随着至少一次的Minor GC(但并非绝对的,在ParallelScavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。MajorGC的速度一般会比Minor GC慢10倍以上。

     对象优先在Eden上分配

     对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲区,将线程优先在 (TLAB) 上分配。少数情况会直接分配在老年代中。

     大对象直接进入老年代

     所谓大对象就是需要大量连续空间的Java对象,最典型的大对象就是那种很长的字符串及数组。虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这些设置值的对象直接在老年代中分配。这样做的目的是在避免Eden区及两个Survivor区之间发生大量的内存拷贝

     长期存活的对象将进入老年代

     虚拟机既然采用分代收集的思想来管理内存,那么内存回收时就必须能够识别哪些对象应当放在新生代,哪些对象应该放在老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能够被Survivor容纳的话(容纳不下的话,按照担保原则进入老年代),将被移动到Survivor空间中,并将对象年龄设置为1.对象在Survivor区每熬过一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度(默认15岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold来设置。

     动态对象年龄判定

     为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间的相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

     空间分配担保

     在发生Minor GC时,虚拟机就会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间,如果大于,则改为直接进行一次Full GC。如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC:如果不允许,则也要改为进行一次Full GC。

     前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用了其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况下,就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代。但是前提老年代本身还有足够空间容纳这些对象。但是实际完成内存回收前是无法知道多少对象存活,所以只好取之前每一次回收晋升到老年代对象容量的平均值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多的空间。

类文件结构

     Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,无空隙存在。对于超过8个字节的数据,将按照Big-Endian的顺序存储的,也就是说高位字节存储在低的地址上面,而低位字节存储到高地址上面(也就是高位在前的方式)分割成若干个8个字节进行存储。 Class文件结构采用类似C语言的结构体来存储数据的,这种结构体只有两种数据类型:无符号数和表。后面的解析都是以这两种数据类型为基础,这里先介绍这两个概念:

  • 无符号数是基本数据类型,用来表述数字、索引引用以及字符串等,比如 u1,u2,u4,u8分别代表1个字节,2个字节,4个字节,8个字节的无符号数。
  • 表是由多个无符号数以及其它的表组成的复合结构,习惯地以_info结尾。表用于描述有层次关系的符合结构的数据,整个Class文件本质上就是一张表。

     无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一系列连续的某一类型的数据叫做某一类型的集合。

     如图所示,是Class的文件格式,接下来详细解释。 在这里插入图片描述

4.1 魔数和版本号

     Class文件的前四个字节称为魔数,它的唯一作用就是确定这个文件是否为一个能被虚拟机接受的Class文件。很多文件存储标准中都是使用魔数来进行身份识别的,使用魔数而不是扩展名来进行识别主要是基于安全的考虑,因为文件扩展名可以随意的改动。      Class 文件的魔数是用 16 进制表示的“CAFE BABE”,是不是很具有浪漫色彩?

     紧接着的四个字节是版本信息,5-6 字节表示次版本号,7-8 字节表示主版本号,它们表示当前 Class 文件中使用的是哪个版本的 JDK。Java的版本号是从45开始的,JDK1.1之后的每个JDK主版本向上加一。高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件,即时文件格式并未发生任何变化,虚拟机也必需拒绝执行超过其版本号的 Class 文件。

4.2 常量池

     主次版本号之后就是常量池入口,常量池可以理解为Class文件之中的资源仓库。它是Class文件中和其它项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它也是Class文件中第一个出现的表类型数据项目。由于常量池的数量不是固定的,所以在常量池的入口放置一项U2类型的数据代表常量池容量计数值。(这个数是从1开始而不是从0开始的)      在Class文件中,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池的项目”的含义,这种情况就可以把索引值置为0来表示。class文件只有常量池的容量是从1开始的,其余都是从0开始的。

常量池中存放两种类型的常量:

  • 字面值常量:字面值常量就是我们在程序中定义的字符串、被 final 修饰的值。
  • 符号引用:符号引用就是我们定义的各种名字:类和接口的全限定名、字段的名字和描述符、方法的名字和描述符。

     虚拟机加载Class文件时进行动态链接,也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期的转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。

     常量池的每一项常量都是一个表,表的结构有很多种,表开始的第一位是一个 u1 类型的标志位(tag),如下图,代表当前这个常量属于哪种常量类型。这十几种常量类型每种均有自己的结构,较复杂。

在这里插入图片描述 举个例子:

对于 CONSTANT_Class_info(此类型的常量代表一个类或者接口的符号引用),它的二维表结构如下: 在这里插入图片描述 tag 是标志位,用于区分常量类型;name_index 是一个索引值,它指向常量池中一个 CONSTANT_Utf8_info 类型常量,此常量代表这个类(或接口)的全限定名,这里 name_index 值若为 0x0002,也即是指向了常量池中的第二项常量。

CONSTANT_Utf8_info 型常量的结构如下: 在这里插入图片描述 tag 是当前常量的类型;length 表示这个字符串的长度;bytes 是这个字符串的内容(采用缩略的 UTF8 编码);

    顺便提一下,在JDK的bin下有一个专门分析Class文件字节码的工具:javap。

    在分析中可以查看到一些常量从未在代码中出现过,这部分自动生成的常量的确没有在Java代码中直接出现过,但它们会被后面的字段表、方法表、属性表引用到,它们会用来描述一些不方便使用固定字节进行表达的内容。

4.3 访问标志

        在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否被 abstract/final 修饰。 在这里插入图片描述

4.4 类索引、父类索引、接口索引集合

       类索引和父类索引都是一个 u2 类型的数据,而接口索引集合是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。

       由于 Java 不允许多重继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。一个类可能实现了多个接口,因此用接口索引集合来描述。这个集合第一项为 u2 类型的数据,表示索引表的容量,接下来就是接口的名字索引。

       类索引和父类索引用两个 u2 类型的索引值表示,它们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,通过该常量总的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。

4.5 字段表集合

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

       在一个Java中描述一个字段包含哪些信息?可以包括的信息有:字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile)、是否可被序列化(transient修饰符)、字段数据类型和字段名。上述信息中,各个修饰符都是布尔值,适合用标志位来表示。而字段叫什么名字、字段被定义成什么类型无法固定,只能引用常量池中的常量来描述(注意这里,是引用常量池的常量!)。

       字段表的结构如下:

在这里插入图片描述        如上图所示,字段修饰符放在access_flags项目中,它与类中的access_flags项目很类似,都是一个u2类型数据,其中可以设置的标志位和含义如下表: 在这里插入图片描述

       字段表集合中不会出现从父类(或接口)中继承而来的字段,但有可能出现原本 Java 代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。

4.6 方法表集合

       Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,方法表的字段如同字段表的一样,依次是访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)。volatile 关键字 和 transient 关键字不能修饰方法,所以方法表的访问标志中没有 ACC_VOLATILE 和 ACC_TRANSIENT 标志。方法表的属性表集合中有一张 Code 属性表,用于存储当前方法经编译器编译后的字节码指令。

4.7 属性表集合

        在Class文件、字段表、方法表都可以携带自己的属性表集合,用于描述某些场景的专有信息。属性表中不要求各个属性表具有严格的顺序,只要不与已有属性重名即可。每个属性对应一张属性表,属性表的结构如下: 在这里插入图片描述

类加载机制

       上面了解了Class文件存储格式的具体细节,在Class文件中描述的各种具体信息,最终都需要加载到虚拟机之后才可以运行使用。而虚拟机如何加载这些Class文件的呢?接下来我们要说的就是虚拟机的类加载机制,虚拟机把描述类的数据从Class文件加载到内存(例如Class文件常量池加载到方法区的运行时常量池),并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,即虚拟机的类加载机制。

5.1类加载时机

在这里插入图片描述        由上图所示,类的生命周期有7个阶段,其中加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。解析阶段可以在初始化之后再开始(运行时绑定或动态绑定或晚期绑定)。

       虚拟机规范严格规定了有且只有5中情况必须立即对类进行初始化(而加载、验证、准备自然需要在此之前完成),下面一一介绍:

  • 遇到new、getstatic、putstatic或 invokestatic 这 4 条字节码指令时没初始化触发初始化。使用场景:使用 new 关键字实例化对象、读取一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法。
  • 使用 java.lang.reflect 包的方法对类进行反射调用的时候。
  • 当初始化一个类的时候,如果发现其父类还没有进行初始化,则需先触发其父类的初始化。
  • 当虚拟机启动时,用户需指定一个要加载的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
  • 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化。

       前面的五种方式是对一个类的主动引用,除此之外,所有引用类的方法都不会触发初始化,称为被动引用。看下面的例子:

public class SuperClass {
	static {
		System.out.println("SuperClass init!");
	}
	public static int value = 1024;
}

public class SubClass extends SuperClass {
	static {
		System.out.println("SubClass init!");
	}
}

public class ConstClass {
	static {
		System.out.println("ConstClass init!");
	}
	public static final String HELLOWORLD = "hello world!"
}

public class NotInitialization {
	public static void main(String[] args) {
		/**
		 * 非主动使用类字段演示 output : SuperClass init!
		 * 
		 * 通过子类引用父类的静态对象不会导致子类的初始化 只有直接定义这个字段的类才会被初始化
		 */
		System.out.println(SubClass.value);

		/**
		 * 通过数组引用未定义类,不会触发此类的初始化 output : SuperClass init!
		 * 
		 * 通过子类引用父类的静态对象不会导致子类的初始化 只有直接定义这个字段的类才会被初始化
		 */
		SuperClass[] sca = new SuperClass[10];

		/**
		 * output :
		 * 
		 * 常量在编译阶段会存入调用类的常量池当中,本质上并没有直接引用到定义类常量的类, 因此不会触发定义常量的类的初始化。 “hello world”
		 * 在编译期常量传播优化时已经存储到 NotInitialization 常量池中了,以后NotInitialization 对常量
		 * ConstClass.HELLOWORLD的引用时机上都转化成NotInitialization 类对自身常量池的引用了。
		 * (NotInitialization 的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译之后便不存在联系了)
		 */
		System.out.println(ConstClass.HELLOWORLD);

	}
}

     针对接口的特殊说明:上面介绍的类的初始化场景第3中,当一个类初始化时要求其父类全部都已经初始化过了,但是在一个接口初始化时,并不要求其父接口都已经初始化,只有在真正使用父接口的时候(如引用到接口中定义的常量)才会初始化。

5.2类加载过程

     下面讲解类加载的全过程,也就是加载、验证、准备、解析和初始化这5个阶段;

     (1)加载

”加载“是”类加机制”的第一个过程,在加载阶段,虚拟机主要完成三件事:

  1. 通过一个类的全限定名来获取其定义的二进制字节流(ZIP 包、网络、运算生成、JSP 生成、数据库读取)。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法去这个类的各种数据的访问入口。

     数组类的特殊性:数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终是要靠类加载器去创建的,数组创建过程如下:

  1. 如果数组的组件类型是引用类型,那就递归采用类加载加载。
  2. 如果数组的组件类型不是引用类型(int[] 数组),Java 虚拟机会把数组标记为引导类加载器关联。
  3. 数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为 public。

     内存中实例的 java.lang.Class 对象存在方法区中,作为程序访问方法区中这些类型数据的外部接口。加载阶段与连接阶段的部分内容是交叉进行的,但是开始时间保持先后顺序。相对于类加载的其他阶段而言,加载阶段是可控性最强的阶段,因为程序员可以使用系统的类加载器加载,还可以使用自己的类加载器加载。

     (2)验证

     验证属于连接的第一步,确保 Class 文件的字节流中包含的信息符合当前虚拟机要求。说白了也就是我们加载好的.class文件不能对我们的虚拟机有危害,所以先检测验证一下。他主要是完成四个阶段的验证:

  • 文件格式验证:验证.class文件字节流是否符合class文件的格式的规范,并且能够被当前版本的虚拟机处理。这里面主要对魔数、主版本号、常量池等等的校验(魔数、主版本号都是.class文件里面包含的数据信息)。
  • 元数据验证:主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求,比如说验证这个类是不是有父类,类中的字段方法是不是和父类冲突等等。
  • 字节码验证:这是整个验证过程最复杂的阶段,主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证阶段对数据类型做出验证后,这个阶段主要对类的方法做出分析,保证类的方法在运行时不会做出危害虚拟机安全的事。
  • 符号引用验证:验证的最后一个阶段,发生在虚拟机将符号引用转化为直接引用的时候。主要是对类自身以外的信息进行校验。目的是确保解析动作能够完成。

     对整个类加载机制而言,验证阶段是一个很重要但是非必需的阶段,如果我们的代码能够确保没有问题,那么我们就没有必要去验证,毕竟验证需要花费一定的的时间。当然我们可以使用-Xverfity:none来关闭大部分的验证。

     (3)准备

     这个阶段正式为类分配内存并设置类变量初始值,内存在方法去中分配(含 static 修饰的变量不含实例变量)。类变量(static)会分配内存,但是实例变量不会,实例变量主要随着对象的实例化一块分配到java堆中,这里的初始值指的是数据类型默认值,而不是代码中被显示赋予的值。      比如:public static int value = 1; //在这里准备阶段过后的value值为0,而不是1。赋值为1的动作在初始化阶段。当然还有其他的默认值。 在这里插入图片描述

      注意,在上面value是被static所修饰的准备阶段之后是0,但是如果同时被final和static修饰准备阶段之后就是1了。我们可以理解为static final在编译器就将结果放入调用它的类的常量池中了。       如果类字段的字段属性表中存在 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 1127。

     (4)解析      这个阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量。

  • 直接引用:直接引用可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和迅疾的内存布局实现有关

     解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行,分别对应于常量池的 7 中常量类型。

     (5)初始化

     这是类加载机制的最后一步,在这个阶段,java程序代码才开始真正执行。我们知道,在准备阶段已经为类变量赋过一次值。在初始化阶端,程序员可以根据自己的需求来赋值了。一句话描述这个阶段就是执行类构造器< clinit >()方法的过程。在初始化阶段,主要为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。

5.3类加载器

     类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。一旦一个类被加载如JVM中,同一个类就不会被再次载入了。正如一个对象有一个唯一的标识一样,一个载入JVM的类也有一个唯一的标识。在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。

      JVM预定义有三种类加载器,当一个 JVM启动的时候,Java开始使用如下三种类加载器:

  • 根类加载器(Bootstrap ClassLoader):它用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader(负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。下面程序可以获得根类加载器所加载的核心类库,并会看到本机安装的Java环境变量指定的jdk中提供的核心jar包路径:
		URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
		for(URL url : urls){
			System.out.println(url.toExternalForm());
		}

  • 扩展类加载器(Extensions ClassLoader):它负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。由Java语言实现,父类加载器为null。
  • 系统类加载器(Appclass Loader):也称为SystemAppClass,它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH换将变量所指定的JAR包和类路径。程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由Java语言实现,父类加载器为ExtClassLoader。

5.3.1 双亲委派模型

     从 Java 虚拟机角度讲,只存在两种类加载器:一种是启动类加载器(C++ 实现,是虚拟机的一部分);另一种是其他所有类的加载器(Java 实现,独立于虚拟机外部且全继承自 java.lang.ClassLoader)

在这里插入图片描述      如图所示展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器。这里的类加载器之间的父子关系一般不会以继承的关系来实现,而是通过使用组合关系来复用父加载器的代码。

工作过程:      如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

优势:      采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

自定义类加载器:

  • 遵守双亲委派模型:继承ClassLoader,重写findClass()方法。
  • 破坏双亲委派模型:继承ClassLoader,重写loadClass()方法。 通常我们推荐采用第一种方法自定义类加载器,最大程度上的遵守双亲委派模型。

5.3.2 破坏双亲委派模型

     双亲委派模型主要3次较大规模的“被破坏”情况,接下来一一介绍。(待补充)

  1. 第一次被破坏发生在双亲委派模型出现之前,因为双亲委派模型是在JDK1.2之后才引入的,但是在JDK1.0之前就已经有用户自定义的类加载器存在了,所以Java的设计者在引入双亲委派模型时不得不做出一些妥协。
  2. 第二次由于该模型本身的缺陷所导致的,双亲委派模型很好的解决的各个类加载器的基础类为同一个的问题,但是如果是这个基础类又想要都调用用户所写的代码时,就会有问题产生了。比如JNDI服务的问题,JNDI服务是java的一个基础服务,这个服务就是为了对资源进行统一的管理和查找,它的代码由启动类进行加载,需要调用独立厂商实现并部署在应用程序的ClassPath下的JNDI接口,但是启动类使不认识这些代码的,这时,引入了线程上下文类加载器(Thread Context ClassLoader() ),如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
  3. 第三次破坏是由于用户过于追求程序的“动态性”导致的,这个“动态性”是指一些热门的名词,例如:代码的热交换、模块的热部署等等,就是说不用重启电脑,直接部署就可以用。

     破坏双亲委派模型的例子:

     原生的JDBC中Driver驱动本身只是一个接口,并没有具体的实现,具体的实现是由不同数据库类型去实现的。例如,MySQL的mysql-connector-.jar中的Driver类具体实现的。 原生的JDBC中的类是放在rt.jar包的,是由启动类加载器进行类加载的,在JDBC中的Driver类中需要动态去加载不同数据库类型的Driver类,而mysql-connector-.jar中的Driver类是用户自己写的代码,那启动类加载器肯定是不能进行加载的,既然是自己编写的代码,那就需要由应用程序启动类去进行类加载。于是乎,这个时候就引入线程上下文件类加载器(Thread Context ClassLoader)。有了这个东西之后,程序就可以把原本需要由启动类加载器进行加载的类,由应用程序类加载器去进行加载了。

作者寄语

     本文是学习了周志明老师的《深入理解Java虚拟机》之后的总结,很多内容来自于书中,也包含作者的一些理解。一是为了梳理知识点,归纳总结,二是为了分享交流,如有错误之处还望指出。

     你知道的越多,你不知道的也越多。keep hungry keep foolish!

==     原创不易,觉得不错的给个关注吧!== 在这里插入图片描述