JVM 知识点

JVM 运行时内存区域划分

主要分为五个区:程序计数器、虚拟机栈、本地方法栈、方法区、堆

程序计数器

可以看作是当前线程所执行的字节码的行号指示器

为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器

如果线程执行的是一个 Java 方法,则计数器记录的是正在执行的虚拟机字节码指令的地址。如果是 Native 方法,则计数器没空(Undefined)

此区域内存是唯一一个在 JVM 规范中没有规定任何 OOM Error 情况的区域

Java 虚拟机栈

Java 虚拟机栈同样是线程私有的,生命周期与线程相同

虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个战阵在虚拟机栈中入栈到出栈的过程

局部变量表存放了编译器可知的各种基本数据类型、对象引用和 returnAddress 类型(指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小

存在两种异常情况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常
  • 如果虚拟机栈在动态扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常

本地方法栈

本地方法栈和 Java 虚拟机栈的区别在于:虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈为虚拟机使用到的 Native 方法服务

Java 堆

此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存,是各个线程共享的内存区域

从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,故 Java 堆中还可以细分:新生代和老年代;再细致一点的有 Eden 空间、From Survivior 空间、To Survivor 空间。从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)

可以通过 -Xmx 和 -Xms 参数设置

如果在堆中没有内存完成实例分配且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常

方法区

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

当方法区午饭满足内存分配的需求时,将抛出 OutOfMemoryError 异常

运行时常量池

用于存放编译期生成的各种字面量和符号引用

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是 JVM 规范中定义的内存区域

NIO 类中引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。因为避免了在 Java 堆和 Native 堆中来回复制数据,在哪些分配次数少,读写操作很频繁的场景下能显著提高性能


对象的创建

创建流程如下:

  1. 虚拟机遇到一条 new 指令时,检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,则必须先执行相应的类加载过程
  2. 为新生对象分配内存,对象所需的内存大小在类加载完成后便可完全确定,为对象分配空间有两种方式:
    • 假如 Java 堆中内存是绝对规整的,已使用和未使用区域各占一边,中间放着一个指针作为分界点的指示器,则分配内存时只需将指针移动一个特定的距离即可。这种方式称为指针碰撞
    • 加入 Java 堆中内存时不规整的,则需要维护一个列表记录哪些内存块是可用的,分配时从列表中找出一个足够大的空间分给新对象,并更新列表。这种方式称为空闲列表
    • 选择那种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整由采用的垃圾收集器是否带有压缩整理功能决定
    • 修改指针会出现线程安全的问题,解决方案有两种:① 对分配内存空间的动作进行同步处理(CAS + 失败重试);② 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定
  3. 内存分配完成后,将分配到的内存空间都初始化为零值(不包括对象头)
  4. 对对象的对象头进行必要的设置
  5. 执行 方法

可达性分析算法

若从 GC Roots 到某个对象不可达,则称此对象是不可用的

可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

强引用、软引用、弱引用、虚引用

  • 强引用就是指在程序代码中普遍存在的,类似”Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
  • 软引用用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常
  • 弱引用也是用来描述非必需对象的,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
  • 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。**为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知

类加载

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制

类的生命周期

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载 7 个阶段。其中验证、准备、解析 3 个部分统称为连接

类初始化的时机

对于初始化阶段,虚拟机规范严格规定了有且只有 5 种情况必须立即对类进行“初始化”

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

这 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用

加载

在加载阶段,虚拟机需要完成一下 3 件事情

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

验证

验证阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全

验证阶段大致上会完成下面 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配

1
public static int value = 123; // 准备阶段过后的初始值为 0 而不是 123,将 value 赋值为 123 是初始化阶段的工作

而如果类字段的字段属性表中存在 ConstantValue 属性,那么准备阶段变量就会被初始化为 ConstantValue 属性所指定的值

1
public static final int value = 123; // 准备阶段过后的初始值为 123

解析

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

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面值,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行

初始化

初始化阶段是执行类构造器 < clinit >() 方法的过程

  • < clinit >() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
  • < clinit >() 方法与类的构造函数(或者说实例构造器 < clinit >() 方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的 < clinit >() 方法执行之前,父类的 < clinit >() 方法已经执行完毕。 因此在虚拟机中第一个被执行的 < clinit >() 方法的类肯定是java.lang.Object
  • 由于父类的 < clinit >() 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作
  • < clinit >() 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 < clinit >() 方法
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 < clinit >() 方法。 但接口与类不同的是,执行接口的 < clinit >() 方法不需要先执行父接口的 < clinit >() 方法。 只有当父接口中定义的变量使用时,父接口才会初始化。 另外,接口的实现类在初始化时也一样不会执行接口的 < clinit >() 方法
  • 虚拟机会保证一个类的 < clinit >() 方法在多线程环境中被正确地加锁、 同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 < clinit >() 方法,其他线程都需要阻塞等待,直到活动线程执行 < clinit >() 方法完毕。 如果在一个类的 < clinit >() 方法中有耗时很长的操作,就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的