JVM原理

Java内存模型

Java程序执行流程

日常Java的基本开发模式是,我们写的所有java程序都保存在*.java的文件中,即我们的源代码,这些源代码必须经过javac.exe命令将其编译成*.class文件,而后利用java.exe命令在JVM进程中解释执行。整个过程如下图:

Java程序执行流程

实际上,当JVM将所需要的 .class 文件加载到 JVM 进程之中,在这个过程需要一个类加载器(ClassLoad),类加载器的好处在于:可以随定指定 *.class 文件所在的路径。

JVM:java虚拟机,所有的程序都要求运行在JVM上,是因为考虑到了可移植性问题 ,但如果真正去执行程序,无法离开操作系统的支持。

在 java 中可以使用 native 实现 本地 C 函数的调用,Native Interface,但是这些都是属于程序的辅助手段,而真正的程序运行都在“运行时数据区”之中。

JVM运行时数据区
JVM运行时数据区

程序计数器

什么是程序计数器

程序计数器是一块较小的内存空间,可以把它看作当前线程正在执行的字节码的行号指示器。也就是说,程序计数器里面记录的是当前线程正在执行的那一条字节码指令的地址。
注:但是,如果当前线程正在执行的是一个本地方法,那么此时程序计数器为空。

程序计数器的作用

程序计数器有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

程序计数器的特点

  1. 是一块较小的存储空间
  2. 线程私有。每条线程都有一个程序计数器。
  3. 是唯一一个不会出现OutOfMemoryError的内存区域。
  4. 生命周期随着线程的创建而创建,随着线程的结束而死亡。

Java虚拟机栈(JVM Stack)

什么是Java虚拟机栈

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

JVM栈
在JVM 中用栈帧(Stack Frame)来定义栈的数据,每一个栈帧表示每个可能执行的方法。

JVM栈帧
而栈帧中则包含了:局部变量表,操作数栈,指向运行时常量池的引用,方法返回地址和动态链接。
局部变量表(Local Variables):方法的局部变量或形参,其以变量槽(solt)为最小单位,只允许保存32为长度的变量,如果超过32位则会开辟两个连续的solt(64位长度,long和double);
操作数栈(Operand Stack):表达式计算在栈中完成;
指向当前方法所属的类的运行时常量池的引用(Reference to runtime constant pool):引用其他类的常量或者使用String 池中的字符串;
方法返回地址(Return Address):方法执行完后需要返回调用此方法的位置,所以需要再栈帧中保存方法返回地址;

Java虚拟机栈的特点

  1. 局部变量表的创建是在方法被执行的时候,随着栈帧的创建而创建。而且,局部变量表的大小在编译时期就确定下来了,在创建的时候只需分配事先规定好的大小即可。此外,在方法运行的过程中局部变量表的大小是不会发生改变的。
  2. Java虚拟机栈会出现两种异常:StackOverFlowError和OutOfMemoryError。
    a) StackOverFlowError:
    若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
    b) OutOfMemoryError:
    若Java虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。
  3. Java虚拟机栈也是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

注:StackOverFlowError和OutOfMemoryError的异同?
StackOverFlowError表示当前线程申请的栈超过了事先定好的栈的最大深度,但内存空间可能还有很多。 解决这种问题可以适当加大栈的深度(增加栈空间大小),也就是把 -Xss 的值设置大一些,但一般情况下是代码问题的可能性较大
而OutOfMemoryError是指当线程申请栈时发现栈已经满了,而且内存也全都用光了。 解决这种问题可以适当减小栈的深度,也就是把 -Xss 的值设置小一些,每个线程占用的空间小了,总空间一定就能容纳更多的线程,但是操作系统对一个进程的线程数有限制,经验值在 3000~5000 左右。
在 jdk1.5 之前 -Xss 默认是 256k,jdk1.5 之后默认是 1M,这个选项对系统影响还是蛮大的,设置时要根据实际情况,谨慎操作。

本地方法栈

什么是本地方法栈

本地方法栈和Java虚拟机栈实现的功能类似,只不过本地方法栈是本地方法运行的内存模型。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间。也会抛出StackOverFlowError和OutOfMemoryError异常。

什么是堆

堆是用来存放对象的内存空间。 几乎所有的对象都存储在堆中。

堆的特点

  1. 线程共享,整个Java虚拟机只有一个堆,所有的线程都访问同一个堆。而程序计数器、Java虚拟机栈、本地方法栈都是一个线程对应一个的。
  2. 在虚拟机启动时创建
  3. 垃圾回收的主要场所。
  4. 可以进一步细分为:新生代、老年代。
    新生代又可被分为:Eden、From Survior、To Survior。
    不同的区域存放具有不同生命周期的对象。这样可以根据不同的区域使用不同的垃圾回收算法,从而更具有针对性,更高效。
  5. 堆的大小既可以固定也可以扩展,但主流的虚拟机堆的大小是可扩展的,因此当线程请求分配内存,但堆已满,且内存已满无法再扩展时,就抛出OutOfMemoryError。

方法区

什么是方法区

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

方法区的特点

  1. 线程共享
    方法区是堆的一个逻辑部分,因此和堆一样,都是线程共享的。整个虚拟机中只有一个方法区。
  2. 永久代
    方法区中的信息一般需要长期存在,而且它又是堆的逻辑分区,因此用堆的划分方法,我们把方法区称为永久代。
  3. 内存回收效率低
    方法区的信息一般需要长期存在,回收一遍内存之后可能只有少量信息无效。
    对方法区的内存回收的主要目标是:对常量池的回收 和 对类的卸载。
  4. Java虚拟机规范对方法区的要求比较宽松。
    和堆一样,允许固定大小,也允许可扩展的大小,还允许不实现垃圾回收。

什么是运行时常量池

方法区中存放三种数据:类信息、常量、静态变量、即时编译器编译后的代码。其中常量存储在运行时常量池中。jdk1,6常量池放在方法区,jdk1.7常量池放在堆内存,jdk1.8放在元空间里面,和堆相独立。
我们一般在一个类中通过public static final来声明一个常量。这个类被编译后便生成Class文件,这个类的所有信息都存储在这个class文件中。
当这个类被Java虚拟机加载后,class文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池中添加新的常量。如:String类的intern()方法就能在运行期间向常量池中添加字符串常量。
当运行时常量池中的某些常量没有被对象引用,同时也没有被变量引用,那么就需要垃圾收集器回收。

直接内存

直接内存是除Java虚拟机之外的内存,但也有可能被Java使用。
在NIO中引入了一种基于通道和缓冲的IO方式。它可以通过调用本地方法直接分配Java虚拟机之外的内存,然后通过一个存储在Java堆中的DirectByteBuffer对象直接操作该内存,而无需先将外面内存中的数据复制到堆中再操作,从而提升了数据操作的效率。
直接内存的大小不受Java虚拟机控制,但既然是内存,当内存不足时就会抛出OOM异常。

Java对象访问模式

对象的创建过程

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

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

    • 若常量池中没有这个类的符号引用,说明这个类还没有被定义!抛出ClassNotFoundException;
    • 若常量池中有这个类的符号引用,则进行下一步工作;
  2. 进而检查这个符号引用所代表的类是否已经被JVM加载;

    • 若该类还没有被加载,就找该类的class文件,并加载进方法区;
    • 若该类已经被JVM加载,则准备为对象分配内存;
  3. 根据方法区中该类的信息确定该类所需的内存大小;
    一个对象所需的内存大小是在这个对象所属类被定义完就能确定的!且一个类所生产的所有对象的内存大小是一样的!JVM在一个类被加载进方法区的时候就知道该类生产的每一个对象所需要的内存大小。

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

指针碰撞

如果JVM的垃圾收集器采用复制算法或标记-整理算法,那么堆中空闲内存是完整的区域,并且空闲内存和已使用内存之间由一个指针标记。那么当为一个对象分配内存时,只需移动指针即可。因此,这种在完整空闲区域上通过移动指针来分配内存的方式就叫做“指针碰撞”。

空闲列表

如果JVM的垃圾收集器采用标记-清除算法,那么堆中空闲区域和已使用区域交错,因此需要用一张“空闲列表”来记录堆中哪些区域是空闲区域,从而在创建对象的时候根据这张“空闲列表”找到空闲区域,并分配内存。

综上所述:JVM究竟采用哪种内存分配方法,取决于它使用了何种垃圾收集器。

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

  2. 设置对象头中的信息;

  3. 调用对象的构造函数进行初始化

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

对象的内存模型

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

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

  1. 对象头
  2. 实例数据
  3. 对齐补充

对象头

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

第二部分是类型指针,即对象指向它的类的元数据指针,虚拟机通过这个指针确定这个对象是哪个类的实例。

另外,如果是 Java 数组,对象头中还必须有一块用于记录数组长度的数据,因为普通对象可以通过 Java 对象元数据确定大小,而数组对象不可以。

实例数据

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

对齐补充

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

访问对象的过程

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

句柄访问方式

堆中需要有一块叫做“句柄池”的内存空间,用于存放所有对象的地址和所有对象所属类的类信息。
引用类型的变量存放的是该对象在句柄池中的地址。访问对象时,首先需要通过引用类型的变量找到该对象的句柄,然后根据句柄中对象的地址再访问对象。
句柄访问方式

这种模式的准确度很高,但过程较为繁琐,而java中是没有句柄的,所以,java中直接利用的对象保存模式,也就是说堆内存中,不需要保存句柄,而直接保存具体的对象。就相当于省略了句柄到对象之间的查找。而后这个对象可以直接进行Java方法区的调用。

直接指针访问方式

引用类型的变量直接存放对象的地址,从而不需要句柄池,通过引用能够直接访问对象。 但对象所在的内存空间中需要额外的策略存储对象所属的类信息的地址。
直接指针访问方式

比较

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

垃圾收集策略

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

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

然而,堆和方法区中的内存清理工作就没那么容易了。
堆和方法区所有线程共享,并且都在JVM启动时创建,一直得运行到JVM停止时。因此它们没办法根据线程的创建而创建、线程的结束而释放。

堆中存放JVM运行期间的所有对象,虽然每个对象的内存大小在加载该对象所属类的时候就确定了,但究竟创建多少个对象只有在程序运行期间才能确定。
方法区中存放类信息、静态成员变量、常量。类的加载是在程序运行过程中,当需要创建这个类的对象时才会加载这个类。因此,JVM究竟要加载多少个类也需要在程序运行期间确定。
因此,堆和方法区的内存回收具有不确定性,因此垃圾收集器在回收堆和方法区内存的时候花了一些心思。

堆内存的回收

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

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

  1. 引用计数法
    每个对象都有一个计数器,当这个对象被一个变量或另一个对象引用一次,该计数器加一;若该引用失效则计数器减一。当计数器为0时,就认为该对象是无效对象。
    引用计数法虽然简单,但存在一个严重的问题,它无法解决循环引用的问题。(比如对象A引用对象B,同时,对象B也引用对象A,造成循环引用)
    引用计数法
    引用计数法

  2. 可达性分析法
    所有和GC Roots直接或间接关联的对象都是有效对象,和GC Roots没有关联的对象就是无效对象。
    可达性分析法

GC Roots是指:

  • Java虚拟机栈所引用的对象(栈帧中局部变量表中引用类型的变量所引用的对象)
  • 方法区中静态属性引用的对象
  • 方法区中常量所引用的对象
  • 本地方法栈所引用的对象

注意:GC Roots并不包括堆中对象所引用的对象!这样就不会出现循环引用。

回收无效对象的过程

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

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

    • 若已覆盖该方法,并该对象的finalize()方法还没有被执行过,那么就会将finalize()扔到F-Queue队列中;
    • 若未覆盖该方法,则直接释放对象内存。
  2. 执行F-Queue队列中的finalize()方法
    虚拟机会以较低的优先级执行这些finalize()方法们,也不会确保所有的finalize()方法都会执行结束。如果finalize()方法中出现耗时操作,虚拟机就直接停止执行,将该对象清除。

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

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

方法区的内存回收

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

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

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

  1. 废弃常量
  2. 废弃的类

如何清除废弃常量

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

如何清除废弃的类

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

  1. 该类的所有对象都已被清除
  2. 该类的java.lang.Class对象没有被任何对象或变量引用
    只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除。
  3. 加载该类的ClassLoader已经被回收

垃圾收集算法

标记-清除算法

首先利用刚才介绍的方法判断需要清除哪些数据,并给它们做上标记;然后清除被标记的数据。
这种算法标记和清除过程效率都很低,而且清除完后存在大量碎片空间,导致无法存储大对象,降低了空间利用率。
标记-清除算法

复制算法

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

分析:

这种算法避免了碎片空间,但内存被缩小了一半。而且每次都需要将有用的数据全部复制到另一片内存上去,效率不高。

解决空间利用率问题:

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

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

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

“分配担保”策略详解

当垃圾收集器准备要在新生代发起一次MinorGC时,首先会检查“老年代中最大的连续空闲区域的大小 是否大于 新生代中所有对象的大小?”,也就是老年代中目前能够将新生代中所有对象全部装下?
若老年代能够装下新生代中所有的对象,那么此时进行MinorGC没有任何风险,然后就进行MinorGC。
若老年代无法装下新生代中所有的对象,那么此时进行MinorGC是有风险的,垃圾收集器会进行一次预测:根据以往MinorGC过后存活对象的平均数来预测这次MinorGC后存活对象的平均数。
如果以往存活对象的平均数小于当前老年代最大的连续空闲空间,那么就进行MinorGC,虽然此次MinorGC是有风险的。
如果以往存活对象的平均数大于当前老年代最大的连续空闲空间,那么就对老年代进行一次Full GC,通过清除老年代中废弃数据来扩大老年代空闲空间,以便给新生代作担保。这个过程就是分配担保。

标记-整理算法

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

分析:

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

分代收集算法

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

垃圾回收流程

垃圾回收流程
对于整个GC流程里,最需要处理的就是年轻代和老年代的内存清理操作,而元空间(永久代)都不在GC范围内;

  1. 当现在有一个新的对象产生,那么对象一定需要内存空间,于是现在就需要为该对象进行内存空间的申请。
  2. 首先会判断伊甸园区是否有内存空间,如果此时有内存空间,则将新对象保存在伊甸园区;
  3. 但如果伊甸园区的内存空间不足,那么会自动执行一个 Minor GC 操作,将伊甸园区无用的内存空间进行清理,当清理之后会继续判断伊甸园区的内存空间是否充足?充足则将新的对象进行空间分配;
  4. 如果执行了 Minor GC 之后发现伊甸园区的内存依然不足,那么这个时候会进行存货区判断,如果存活区有剩余空间,则将伊甸园区的部分对象保存在存活区,那么随后继续判断伊甸园区的内存空间是否充足,如何内存充足,则在伊甸园区进行空间分配;
  5. 如果此时存活区也已经没有内存空间了,则开始判断老年区,如果此时老年区的空间充足,则将存活区中的活跃对象保存在老年代,而后存活区就会存现有空余空间,随后,伊甸园区将活跃对象保存在存活区之中,而后在伊甸园区里为新对象开辟内存空间;
  6. 如果这个时候老年代也满了,那么这个时候将产生 Major GC(Full GC),进行老年代的内存清理;
  7. 如果老年代执行了 Full GC 之后,依然无法进行对象的保存,就会产生 OOM()异常“OutOfMemoryError”。

Java中引用的种类

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

强引用

我们平时所使用的引用就是强引用。
A a = new A();
也就是通过关键字new创建的对象所关联的引用就是强引用。
只要强引用存在,该对象永远也不会被回收。

软引用

只有当堆即将发生OOM异常时,JVM才会回收软引用所指向的对象。
软引用通过SoftReference类实现。
软引用的生命周期比强引用短一些。

弱引用

只要垃圾收集器运行,软引用所指向的对象就会被回收。
弱引用通过WeakReference类实现。
弱引用的生命周期比软引用短。

虚引用

虚引用也叫幽灵引用,它和没有引用没有区别,无法通过虚引用访问对象的任何属性或函数。
一个对象关联虚引用唯一的作用就是在该对象被垃圾收集器回收之前会受到一条系统通知。
虚引用通过PhantomReference类来实现。

对象内存分配策略

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

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

而Java虚拟机中的方法区因为是用来存储类信息、常量、静态变量,这些数据的变动性较小,因此不是Java内存管理重点需要关注的区域。

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

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

对象优先在Eden区中分配

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

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

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

大对象直接进入老年代

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

当发现一个大对象在Eden区+Survior1区中存不下的时候就需要分配担保机制把当前Eden区+Survior1区的所有对象都复制到老年代中去。
我们知道,一个大对象能够存入Eden区+Survior1区的概率比较小,发生分配担保的概率比较大,而分配担保需要涉及到大量的复制,就会造成效率低下。
因此,对于大对象我们直接把他放到老年代中去,从而就能避免大量的复制操作。
那么,什么样的对象才是“大对象”呢?

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

该参数用于设置大小超过该参数的对象被认为是“大对象”,直接进入老年代。
注意:该参数只对Serial和ParNew收集器有效

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

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

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

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

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

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

HotSpot垃圾收集器

HotSpot垃圾收集器

说明:如果两个收集器之间存在连线说明他们之间可以搭配使用。

新生代垃圾收集器

Serial垃圾收集器

  1. 单线程
    只开启一条GC线程进行垃圾回收,并且在垃圾回收过程中停止一切用户线程,从而用户的请求或图形化界面会出现卡顿。

  2. 适合客户端应用
    一般客户端应用所需内存较小,不会创建太多的对象,而且堆内存不大,因此垃圾回收时间比较短,即使在这段时间停止一切用户线程,用户也不会感受到明显的停顿,因此本垃圾收集器适合客户端应用。

  3. 简单高效
    由于Serial收集器只有一条GC线程,因此避免了线程切换的开销,从而简单高效。

  4. 采用“复制”算法

Serial垃圾收集器

ParNew垃圾收集器

ParNew是Serial的多线程版本。

  1. 多线程并行执行
    ParNew由多条GC线程并行地进行垃圾清理。但清理过程仍然需要停止一切用户线程。但由于有多条GC线程同时清理,清理速度比Serial有一定的提升。

  2. 适合多CPU的服务器环境
    由于使用了多线程,因此适合CPU较多的服务器环境。

  3. 与Serial性能对比
    ParNew和Serial唯一的区别就是使用了多线程进行垃圾回收,在多CPU的环境下性能比Serial会有一定程度的提升;但线程切换需要额外的开销,因此在单CPU环境中表现不如Serial。

  4. 采用“复制”算法

  5. 追求“降低停顿时间”
    和Serial相比,ParNew使用多线程的目的就是缩短垃圾收集时间,从而减少用户线程被停顿的时间。

ParNew垃圾收集器

并行(Parallel):指多条垃圾收集线程并行工作,此时用户线程处于等待状态。

并发(Concurrent):指用户线程和垃圾回收线程同时执行(不一定是并行,有可能是交叉执行),用户进程在运行,而垃圾回收线程在另一个 CPU 上运行。

Parallel Scavenge垃圾收集器

Parallel Scavenge和ParNew一样都是多线程、新生代收集器,都使用“复制”算法进行垃圾回收。但它们有个巨大的不同点:ParNew收集器追求降低用户线程的停顿时间,因此适合交互式应用;而Parallel Scavenge追求CPU吞吐量,能够在较短的时间内完成指定任务,因此适合没有交互的后台计算。

  1. 什么是“吞吐量”?
    吞吐量是指用户线程运行时间占CPU总时间的比例。
    CPU总时间包括:用户线程运行时间 和 GC线程运行的时间。
    因此,吞吐量越高表示用户线程运行时间越长,从而用户线程能够被快速处理完。

  2. 降低停顿时间的两种方式

    • 在多CPU环境中使用多条GC线程,从而垃圾回收的时间减少,从而用户线程停顿的时间也减少;
    • 实现GC线程与用户线程并发执行。所谓并发,就是用户线程与GC线程交替执行,从而每次停顿的时间会减少,用户感受到的停顿感降低,但线程之间不断切换意味着需要额外的开销,从而垃圾回收和用户线程的总时间将会延长。
  3. Parallel Scavenge提供的参数

    1. 设置“吞吐量”:通过参数-XX:GCTimeRadio设置垃圾回收时间占总CPU时间的百分比。

    2. 设置“停顿时间”:通过参数-XX:MaxGCPauseMillis设置垃圾处理过程最久停顿时间。Parallel Scavenge会根据这个值的大小确定新生代的大小。如果这个值越小,新生代就会越小,从而收集器就能以较短的时间进行一次回收。但新生代变小后,回收的频率就会提高,因此要合理控制这个值。

    3. 启用自适应调节策略:通过命令-XX:+UseAdaptiveSizePolicy就能开启自适应策略。我们只要设置好堆的大小和MaxGCPauseMillisGCTimeRadio,收集器会自动调整新生代的大小、Eden和Survior的比例、对象进入老年代的年龄,以最大程度上接近我们设置的MaxGCPauseMillis或GCTimeRadio。

老年代垃圾收集器

Serial Old垃圾收集器

Serial Old收集器是Serial的老年代版本,它们都是单线程收集器,也就是垃圾收集时只启动一条GC线程,因此都适合客户端应用。

它们唯一的区别就是Serial Old工作在老年代,使用“标记-整理”算法;而Serial工作在新生代,使用“复制”算法。

Serial Old垃圾收集器

Parallel Old垃圾收集器

Parallel Old收集器是Parallel Scavenge的老年代版本,一般它们搭配使用,追求CPU吞吐量。
它们在垃圾收集时都是由多条GC线程并行执行,并停止一切用户线程。因此,由于在垃圾清理过程中没有使垃圾收集和用户线程并行执行,因此它们是追求吞吐量的垃圾收集器。

Parallel Old垃圾收集器

CMS垃圾收集器

CMS收集器是一款追求停顿时间的老年代收集器,它在垃圾收集时使得用户线程和GC线程并行执行,因此在垃圾收集过程中用户也不会感受到明显的卡顿。但用户线程和GC线程之间不停地切换会有额外的开销,因此垃圾回收总时间就会被延长。

回收过程

  1. 初始标记
    停止一切用户线程,仅使用一条初始标记线程对所有与GC ROOTS直接关联的对象进行标记。速度很快。
  2. 并发标记
    使用多条并发标记线程并行执行,并与用户线程并发执行。此过程进行可达性分析,标记出所有废弃的对象。速度很慢。
  3. 重新标记
    停止一切用户线程,并使用多条重新标记线程并行执行,将刚才并发标记过程中新出现的废弃对象标记出来。这个过程的运行时间介于初始标记和并发标记之间。
  4. 并发清除
    只使用一条并发清除线程,和用户线程们并发执行,清除刚才标记的对象。这个过程非常耗时。

CMS垃圾收集器

CMS的缺点

  1. 吞吐量低
    由于CMS在垃圾收集过程使用用户线程和GC线程并行执行,从而线程切换会有额外开销,因此CPU吞吐量就不如在垃圾收集过程中停止一切用户线程的方式来的高。

  2. 无法处理浮动垃圾,导致频繁Full GC
    由于垃圾清除过程中,用户线程和GC线程并发执行,也就是用户线程仍在执行,那么在执行过程中会产生垃圾,这些垃圾称为“浮动垃圾”。
    如果CMS在垃圾清理过程中,用户线程需要在老年代中分配内存时发现空间不足时,就需要再次发起Full GC,而此时CMS正在进行清除工作,因此此时只能由Serial Old临时对老年代进行一次Full GC。

  3. 使用“标记-清除”算法产生碎片空间
    由于CMS使用了“标记-清除”算法, 因此清除之后会产生大量的碎片空间,不利于空间利用率。不过CMS提供了应对策略:

    • 开启-XX:+UseCMSCompactAtFullCollection
      开启该参数后,每次FullGC完成后都会进行一次内存压缩整理,将零散在各处的对象整理到一块儿。但每次都整理效率不高,因此提供了以下参数。
    • 设置参数-XX:CMSFullGCsBeforeCompaction
      本参数告诉CMS,经过了N次Full GC过后再进行一次内存整理。

通用垃圾收集器–G1垃圾收集器

G1的特点

  1. 追求停顿时间
  2. 多线程GC
  3. 面向服务端应用
  4. 标记-整理和复制算法合并不会产生碎片内存。
  5. 可对整个堆进行垃圾回收
  6. 可预测停顿时间

G1的内存模型

G1垃圾收集器没有新生代和老年代的概念了,而是将堆划分为一块块独立的Region。当要进行垃圾收集时,首先估计每个Region中的垃圾数量,每次都从垃圾回收价值最大的Region开始回收,因此可以获得最大的回收效率。

Remembered Set

一个对象和它内部所引用的对象可能不在同一个Region中,那么当垃圾回收时,是否需要扫描整个堆内存才能完整地进行一次可达性分析?

当然不是,每个Region都有一个Remembered Set,用于记录本区域中所有对象引用的对象所在的区域,从而在进行可达性分析时,只要在GC ROOTs中再加上Remembered Set即可防止对所有堆内存的遍历。

G1垃圾收集过程

  1. 初始标记
    标记与GC ROOTS直接关联的对象,停止所有用户线程,只启动一条初始标记线程,这个过程很快。
  2. 并发标记
    进行全面的可达性分析,开启一条并发标记线程与用户线程并行执行。这个过程比较长。
  3. 最终标记
    标记出并发标记过程中用户线程新产生的垃圾。停止所有用户线程,并使用多条最终标记线程并行执行。
  4. 筛选回收
    回收废弃的对象。此时也需要停止一切用户线程,并使用多条筛选回收线程并行执行。

G1垃圾收集器

Class文件结构

什么是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运行它们。

Class文件基本组织结构

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

  • 无符号数
    它表示class文件中的值,这些值没有任何类型,但有不同的长度。根据这些值长度的不同分为:u1、u2、u4、u8,分别代表1字节的无符号数、2字节的无符号数、4字节的无符号数、8字节的无符号数。

  • class文件中所有数据(即无符号数)要么单独存在,要么由多个无符号数组成二维表。即class文件中的数据要么是单个值,要么是二维表。

Class文件基本组织结构

魔数(magic)

所有的由Java编译器编译而成的class文件的前4个字节都是“0xCAFEBABE”
它的作用在于:当JVM在尝试加载某个文件到内存中来的时候,会首先判断此class文件有没有JVM认为可以接受的“签名”,即JVM会首先读取文件的前4个字节,判断该4个字节是否是“0xCAFEBABE”,如果是,则JVM会认为可以将此文件当作class文件来加载并使用。

版本号(minor_version,major_version)

紧接着魔数的4个字节是版本号。它表示本class中使用的是哪个版本的JDK。
主版本号和次版本号在class文件中各占两个字节,副版本号占用第5、6两个字节,而主版本号则占用第7,8两个字节。JDK1.0的主版本号为45,以后的每个新主版本都会在原先版本的基础上加1。若现在使用的是JDK1.7编译出来的class文件,则相应的主版本号应该是51,对应的7,8个字节的十六进制的值应该是 0x33。

一个 JVM实例只能支持特定范围内的主版本号 (Mi 至Mj) 和 0 至特定范围内 (0 至 m) 的副版本号。假设一个 Class 文件的格式版本号为 V, 仅当Mi.0 ≤ v ≤ Mj.m成立时,这个 Class 文件才可以被此 Java 虚拟机支持。不同版本的 Java 虚拟机实现支持的版本号也不同,高版本号的 Java 虚拟机实现可以支持低版本号的 Class 文件,反之则不成立。

JVM在加载class文件的时候,会读取出主版本号,然后比较这个class文件的主版本号和JVM本身的版本号,如果JVM本身的版本号 < class文件的版本号,JVM会认为加载不了这个class文件,会抛出我们经常见到的"java.lang.UnsupportedClassVersionError: Bad version number in .class file " Error 错误;反之,JVM会认为可以加载此class文件,继续加载此class文件。

常量池(constant_pool)

什么是常量池

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

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

常量池信息表示

常量池的特点

  1. 常量池长度不固定
    常量池的大小是不固定的,因此常量池开头放置一个u2类型的无符号数,用来存储当前常量池的容量。JVM根据这个值就知道常量池的头尾来。
    注:这个值是从1开始的,若为5表示池中有4个常量。
  2. 常量池中的常量由而为表来表示
    常量池开头有个常量池容量计数器,接下来就全是一个个常量了,只不过常量都是由一张张二维表构成,除了记录常量的值以外,还记录当前常量的相关信息。
  3. 常量池是class文件的资源仓库
  4. 常量池是与本class中其它部分关联最多的部分
  5. 常量池是class文件中空间占用最大的部分之一

常量池中常量的类型

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

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

类型 名称 数量
u1 tag 1
u2 length 1

tag表示当前常量的类型(当前常量为CONSTANT_Class_info,因此tag的值应为7,表示一个类或接口的全限定名);
name_index表示这个类或接口全限定名的位置。它的值表示指向常量池的第几个常量。它会指向一个CONSTANT_Utf8_info类型的常量,它的二维表结构如下:

类型 名称 数量
u1 tag 1
u2 length 1
u1 bytes length

CONSTANT_Utf8_info表示字符串常量;
tag表示当前常量的类型,这里应该是1;
length表示这个字符串的长度;
bytes为这个字符串的内容(采用缩略的UTF8编码)

常量类型

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

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

访问标志(access_flags)

在常量池之后是2字节的访问标志。访问标志是用来表示这个class文件是类还是接口、是否被public修饰、是否被abstract修饰、是否被final修饰等。
由于这些标志都由是/否表示,因此可以用0/1表示。
访问标志为2字节,可以表示16位标志,但JVM目前只定义了8种,未定义的直接写0.
访问标志

访问标志示意图

类索引(this_class)

类索引,this_class的值必须是对constant_pool表中项目的一个有效索引值。constant_pool表在这个索引处的项必须为CONSTANT_Class_info 类型常量,表示这个 Class 文件所定义的类或接口。

父类索引(super_class)

父类索引,对于类来说,super_class 的值必须为 0 或者是对constant_pool 表中项目的一个有效索引值。如果它的值不为 0,那 constant_pool 表在这个索引处的项必须为CONSTANT_Class_info 类型常量,表示这个 Class 文件所定义的类的直接父类。当前类的直接父类,以及它所有间接父类的access_flag 中都不能带有ACC_FINAL 标记。对于接口来说,它的Class文件的super_class项的值必须是对constant_pool表中项目的一个有效索引值。constant_pool表在这个索引处的项必须为代表 java.lang.Object 的 CONSTANT_Class_info 类型常量 。如果 Class 文件的 super_class的值为 0,那这个Class文件只可能是定义的是java.lang.Object类,只有它是唯一没有父类的类。

接口计数器(interfaces_count)

接口计数器,interfaces_count的值表示当前类或接口的直接父接口数量。

接口信息数据区(interfaces[interfaces_count])

接口表,interfaces[]数组中的每个成员的值必须是一个对constant_pool表中项目的一个有效索引值, 它的长度为 interfaces_count。每个成员 interfaces[i] 必须为 CONSTANT_Class_info类型常量,其中 0 ≤ i <interfaces_count。在interfaces[]数组中,成员所表示的接口顺序和对应的源代码中给定的接口顺序(从左至右)一样,即interfaces[0]对应的是源代码中最左边的接口。

字段表的集合

什么是字段表集合

字段表集合用于存储本类所涉及到的成员变量,包括实例变量和类变量,但不包括方法中的局部变量。
每一个字段表只表示一个成员变量,本类中所有的成员变量构成了字段表集合。

描述一个字段的信息包括:字段的作用域(public、protected、private)、实例变量与否(static)、可变性(final)、并发可见性(volatile)、可否被序列化(transient)、字段数据类型(基本数据类型、对象、数组)、字段名称。而字段叫什么名字,字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。
字段表集合
filedInfo

字段表结构的定义

类型 名称 数量
u2 access_flags 1
u2 name_index 1
u2 descriptor_index 1
u2 attributes_count 1
attribute_info attributes attributes_count
  • access_flags
    字段的访问标志。在Java中,每个成员变量都有一系列的修饰符,和上述class文件的访问标志的作用一样,只不过成员变量的访问标志与类的访问标志稍有区别。
    访问标识

  • name_index
    本字段名字的索引。指向一个CONSTANT_Class_info类型的常量,这里面存储了本字段的名字等信息。

  • descriptor_index
    描述符。用于描述本字段在Java中的数据类型等信息(下面详细介绍)

  • attributes_count
    属性表集合的长度。

  • attributes
    属性表集合。到descriptor_index为止是字段表的固定信息,光有上述信息可能无法完整地描述一个字段,因此用属性表集合来存放额外的信息,比如一个字段的值。

什么是描述符

成员变量(包括静态成员变量和实例变量) 和 方法都有各自的描述符。
对于字段而言,描述符用于描述字段的数据类型;
对于方法而言,描述符用于描述字段的数据类型、参数列表、返回值。

在描述符中,基本数据类型用大写字母表示,对象类型用“L对象类型的全限定名”表示,数组用“数组类型的全限定名”表示。
描述方法时,将参数根据上述规则放在()中,()右侧按照上述方法放置返回值。而且,参数之间无需任何符号。

字段表集合的注意点

  1. 一个class文件的字段表集合中不能出现从父类/接口继承而来字段;
  2. 一个class文件的字段表集合中可能会出现程序猿没有定义的字段
    如编译器会自动地在内部类的class文件的字段表集合中添加外部类对象的成员变量,供内部类访问外部类。
  3. Java中只要两个字段名字相同就无法通过编译。但在JVM规范中,允许两个字段的名字相同但描述符不同的情况,并且认为它们是两个不同的字段。

方法表的集合

在class文件中,所有的方法以二维表的形式存储,每张表来表示一个函数,一个类中的所有方法构成方法表的集合。
方法表的结构和字段表的结构一致,只不过访问标志和属性表集合的可选项有所不同。

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

方法表集合结构
一个类中方法包含的信息

方法表集合的注意点

  1. 如果本class没有重写父类的方法,那么本class文件的方法表集合中是不会出现父类/父接口的方法表;
  2. 本class的方法表集合可能出现程序猿没有定义的方法
    编译器在编译时会在class文件的方法表集合中加入类构造器和实例构造器。
  3. 重载一个方法需要有相同的简单名称和不同的特征签名。JVM的特征签名和Java的特征签名有所不同:
    • Java特征签名:方法参数在常量池中的字段符号引用的集合
    • JVM特征签名:方法参数+返回值

属性表

属性计数器(attributes_count)

属性计数器,attributes_count的值表示当前 Class 文件attributes表的成员个数。attributes表中每一项都是一个attribute_info 结构的数据项。

属性信息数据区(attributes[attributes_count])

属性表,attributes 表的每个项的值必须是attribute_info结构。

在Java 7 规范里,Class文件结构中的attributes表的项包括下列定义的属性: InnerClasses 、 EnclosingMethod 、 Synthetic 、Signature、SourceFile,SourceDebugExtension 、Deprecated、RuntimeVisibleAnnotations 、RuntimeInvisibleAnnotations以及BootstrapMethods属性。

对于支持 Class 文件格式版本号为 49.0 或更高的 Java 虚拟机实现,必须正确识别并读取attributes表中的Signature、RuntimeVisibleAnnotations和RuntimeInvisibleAnnotations属性。对于支持Class文件格式版本号为 51.0 或更高的 Java 虚拟机实现,必须正确识别并读取 attributes表中的BootstrapMethods属性。Java 7 规范 要求任一 Java 虚拟机实现可以自动忽略 Class 文件的 attributes表中的若干 (甚至全部) 它不可识别的属性项。任何本规范未定义的属性不能影响Class文件的语义,只能提供附加的描述信息 。


根据上述的叙述,我们可以将class的文件组织结构概括成以下面这个结构体:
Class结构体

本文标题:JVM原理

文章作者:王洪博

发布时间:2018年09月02日 - 19:09

最后更新:2019年09月23日 - 07:09

原始链接:http://whb1990.github.io/posts/6f6b6cd5.html

▄︻┻═┳一如果你喜欢这篇文章,请点击下方"打赏"按钮请我喝杯 ☕
0%