JVM概述

JVM:全称 Java Virtual Machine,即 Java 虚拟机,一种规范,本身是一个虚拟计算机,直接和操作系统进行交互,与硬件不直接交互,而操作系统可以帮我们完成和硬件进行交互的工作

JVM-概述图

Java 代码执行流程:java程序 --(编译)--> 字节码文件 --(解释执行)--> 操作系统(Win,Linux)

架构模型

Java 编译器输入的指令流是一种基于栈的指令集架构。因为跨平台的设计,Java 的指令都是根据栈来设计的,不同平台 CPU 架构不同,所以不能设计为基于寄存器架构

  • 基于栈式架构的特点:
    • 设计和实现简单,适用于资源受限的系统
    • 使用零地址指令方式分配,执行过程依赖操作栈,指令集更小,编译器容易实现
      • 零地址指令:机器指令的一种,是指令系统中的一种不设地址字段的指令,只有操作码而没有地址码。这种指令有两种情况:一是无需操作数,另一种是操作数为默认的(隐含的),默认为操作数在寄存器(ACC)中,指令可直接访问寄存器
      • 一地址指令:一个操作码对应一个地址码,通过地址码寻找操作数
    • 不需要硬件的支持,可移植性更好,更好实现跨平台
  • 基于寄存器架构的特点:
    • 需要硬件的支持,可移植性差
    • 性能更好,执行更高效,寄存器比内存快
    • 以一地址指令、二地址指令、三地址指令为主

生命周期

JVM 的生命周期分为三个阶段,分别为:启动、运行、死亡。

  • 启动:当启动一个 Java 程序时,通过引导类加载器(bootstrap class loader)创建一个初始类(initial class),对于拥有 main 函数的类就是 JVM 实例运行的起点

  • 运行

    • main() 方法是一个程序的初始起点,任何线程均可由在此处启动

    • 在 JVM 内部有两种线程类型,分别为:用户线程和守护线程,JVM 使用的是守护线程,main() 和其他线程使用的是用户线程,守护线程会随着用户线程的结束而结束

    • 执行一个 Java 程序时,真真正正在执行的是一个 Java 虚拟机的进程

    • JVM 有两种运行模式 Server 与 Client,两种模式的区别在于:Client 模式启动速度较快,Server 模式启动较慢;但是启动进入稳定期长期运行之后 Server 模式的程序运行速度比 Client 要快很多

      Server 模式启动的 JVM 采用的是重量级的虚拟机,对程序采用了更多的优化;Client 模式启动的 JVM 采用的是轻量级的虚拟机

  • 死亡

    • 当程序中的用户线程都中止,JVM 才会退出
    • 程序正常执行结束、程序异常或错误而异常终止、操作系统错误导致终止
    • 线程调用 Runtime 类 halt 方法或 System 类 exit 方法,并且 java 安全管理器允许这次 exit 或 halt 操作

内存区域

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域

Java运行时数据区域JDK1.8

虚拟机栈

每个 Java 方法在执行的同时会创建一个栈帧(一个方法一个栈帧)用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程

虚拟机栈特点:

  • 虚拟机栈是每个线程私有的,每个线程只能有一个活动栈帧,对应方法调用到执行完成的整个过程
  • 栈内存不需要进行GC,方法开始执行的时候会进栈,方法调用后自动弹栈,相当于清空了数据
  • 栈内存分配越大越大,可用的线程数越少(内存越大,每个线程拥有的内存越大)
  • 方法内的局部变量是否线程安全
    • 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
    • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

异常:

  • StackOverFlowError : 线程请求的栈深度超过最大值
  • OutOfMemoryError : 栈帧过多导致栈内存溢出 (超过了栈的容量)

本地方法栈

本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务

  • 不需要进行GC,与虚拟机栈类似,也是线程私有的,有 StackOverFlowError 和 OutOfMemoryError 异常

  • 虚拟机栈执行的是 Java 方法,在 HotSpot JVM 中,直接将本地方法栈和虚拟机栈合二为一

  • 本地方法一般是由其他语言编写,并且被编译为基于本机硬件和操作系统的程序

  • 当某个线程调用一个本地方法时,就进入了不再受虚拟机限制的世界,和虚拟机拥有同样的权限

    • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
    • 直接从本地内存的堆中分配任意数量的内存
    • 可以直接使用本地处理器中的寄存器

程序计数器

程序计数器(寄存器)主要有两个作用:

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

特点:

  • 是线程私有的
  • 不会存在内存溢出,是 JVM 规范中唯一一个不出现 OOM 的区域,所以这个空间不会进行 GC

程序计数器的生命周期随着线程的创建而创建,随着线程的结束而死亡

所有对象都在这里分配内存,是垃圾收集的主要区域("GC 堆"),由所有线程共享,堆中对象大部分都需要考虑线程安全的问题
存放哪些资源:

  • 对象实例:类初始化生成的对象,基本数据类型的数组也是对象实例,new 创建对象都使用堆内存
  • 字符串常量池:
    • 字符串常量池原本存放于方法区,jdk7 开始放置于堆中
    • 字符串常量池存储的是 String 对象的直接引用或者对象,是一张 string table
  • 静态变量:静态变量是有 static 修饰的变量,jdk7 时从方法区迁移至堆中
  • 线程分配缓冲区 Thread Local Allocation Buffer:线程私有但不影响堆的共性,可以提升对象分配的效率

在 Java7 中堆内会存在年轻代、老年代和方法区(永久代)

  • Young 区被划分为三部分,Eden 区和两个大小严格相同的 Survivor 区。Survivor 区某一时刻只有其中一个是被使用的,另外一个留做垃圾回收时复制对象。在 Eden 区变满的时候, GC 就会将存活的对象移到空闲的 Survivor 区间中,根据 JVM 的策略,在经过几次垃圾回收后,仍然存活于 Survivor 的对象将被移动到 Tenured 区间
  • Tenured 区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在 Young 复制转移一定的次数以后,对象就会被转移到 Tenured 区
  • Perm 代主要保存 Class、ClassLoader、静态变量、常量、编译后的代码,在 Java7 中堆内方法区会受到 GC 的管理

方法区

方法区:是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、即时编译器编译后的代码等数据,虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是也叫 Non-Heap(非堆)

方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式

方法区的大小不必是固定的,可以动态扩展,加载的类太多,可能导致永久代内存溢出 (OutOfMemoryError)

方法区的 GC:针对常量池的回收及对类型的卸载,比较难实现

为了避免方法区出现 OOM,在 JDK8 中将堆内的方法区(永久代)移动到了本地内存上,重新开辟了一块空间,叫做元空间,元空间存储类的元信息,静态变量和字符串常量池等放入堆中

类元信息:在类编译期间放入方法区,存放了类的基本信息,包括类的方法、参数、接口以及常量池表

常量池表(Constant Pool Table)是 Class 文件的一部分,存储了类在编译期间生成的字面量、符号引用,JVM 为每个已加载的类维护一个常量池

  • 字面量:基本数据类型、字符串类型常量、声明为 final 的常量值等
  • 符号引用:类、字段、方法、接口等的符号引用

运行时常量池是方法区的一部分

  • 常量池(编译器生成的字面量和符号引用)中的数据会在类加载的加载阶段放入运行时常量池
  • 类在解析阶段将这些符号引用替换成直接引用
  • 除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()

本地内存

基本介绍

本地内存:又叫做堆外内存,线程共享的区域,本地内存这块区域是不会受到 JVM 的控制的,不会发生 GC;因此对于整个 java 的执行效率是提升非常大,但是如果内存的占用超出物理内存的大小,同样也会报 OOM

本地内存概述图:

JVM-内存图对比


元空间

PermGen被元空间代替,永久代的类信息、方法、常量池等都移动到元空间区

元空间与永久代区别:元空间不在虚拟机中,使用的本地内存,默认情况下,元空间的大小仅受本地内存限制

方法区内存溢出:

  • JDK1.8 以前会导致永久代内存溢出:java.lang.OutOfMemoryError: PerGen space

     -XX:MaxPermSize=8m		#参数设置
    
  • JDK1.8 以后会导致元空间内存溢出:java.lang.OutOfMemoryError: Metaspace

    -XX:MaxMetaspaceSize=8m	#参数设置	
    

直接内存

直接内存是 Java 堆外、直接向系统申请的内存区间,不是虚拟机运行时数据区的一部分,在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。不受jvm内存回收管理

内存分配

堆空间的基本结构:

堆空间的基本结构.png

Minor GC 和 Full GC

  1. Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快
    触发条件:
    • 当 Eden 空间满时,就将触发一次 Minor GC
  2. Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多
    触发条件:
    • 调用 System.gc()
      只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存
    • 老年代空间不足
      老年代空间不足的常见场景大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。
    • 空间分配担保失败
      使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC

分配策略

1. 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。

2. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

避免在 Eden 和 Survivor 之间的大量内存复制而降低效率。

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

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

4. 动态对象年龄判定

虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

5. 空间分配担保

空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。

垃圾回收

主要是针对对象内存的回收和对象内存的分配,垃圾收集最核心的功能是 内存中对象的分配与回收。

判断一个对象是否可被回收

1. 引用计数算法

为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。

2. 可达性分析算法

以 GC Roots 作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则表示可以回收

可达性分析算法.png

GC Roots(Set) 一般包含以下内容:

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

引用类型

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。

Java 提供了四种强度不同的引用类型。

1. 强引用

当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

使用 new 一个新对象的方式来创建强引用,此时它储与可达状态,不可能被垃圾回收,即使该对象以后永远不会被用到。因此强引用是造成内存泄漏的主要原因之一

2. 软引用

被软引用关联的对象只有在内存不够的情况下才会被回收。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。软引用通常用在对内存敏感的程序中,比如高速缓存

public static void main(String[] args) {
final int _4M = 4*1024*1024;
        //使用引用队列,用于移除引用为空的软引用对象
        ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
        //使用软引用对象 list和SoftReference是强引用,而SoftReference和byte数组则是软引用
        List<SoftReference<byte[]>> list = new ArrayList<>();
        SoftReference<byte[]> ref= new SoftReference<>(new byte[_4M]);

        //遍历引用队列,如果有元素,则移除
        Reference<? extends byte[]> poll = queue.poll();
        while(poll != null) {
        //引用队列不为空,则从集合中移除该元素
        list.remove(poll);
        //移动到引用队列中的下一个元素
        poll = queue.poll();
        }
}
	

3. 弱引用

只具有弱引用的对象拥有更短暂的生命周期。被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

4. 虚引用

虚引用并不会决定对象的生命周期,也无法通过虚引用得到一个对象。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收,虚引用必须和引用队列联合使用

虚引用的唯一目的是能在这个对象被回收时收到一个系统通知,用来跟踪对象被垃圾回收的活动。

5. 终结器引用

所有的类都继承自Object类,Object类有一个finalize方法。当某个对象不再被其他的对象所引用时,会先将终结器引用对象放入引用队列中,然后根据终结器引用对象找到它所引用的对象,然后调用该对象的finalize方法。调用以后,该对象就可以被垃圾回收了

垃圾收集算法

1. 标记 - 清除

标记清除

该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  1. 效率问题
  2. 空间问题(标记清除后会产生大量不连续的碎片)

2. 标记 - 复制

标记复制

将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

3. 标记 - 整理

标记整理

让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
优点:

  • 不会产生内存碎片

不足:

  • 需要移动大量对象,处理效率比较低。

4. 分代收集

当前虚拟机的垃圾收集都采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。

  • 新生代使用:标记 - 复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
  • 老年代使用:标记 - 清除 或者 标记 - 整理 算法

垃圾收集器

垃圾收集器
以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

  • 单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程;
  • 串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1之外,其它垃圾收集器都是以串行的方式执行。

1. Serial 收集器(串行)

新生代采用标记-复制算法,老年代采用标记-整理算法。
Serial 收集器

它以串行的方式执行。
它是单线程的收集器,只会使用一个线程进行垃圾收集工作。
它的优点是简单高效,在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。

2. ParNew 收集器(串行)

新生代采用标记-复制算法,老年代采用标记-整理算法。
ParNew 收集器

它是 Serial 收集器的多线程版本。
它是 Server 场景下默认的新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合使用。

3. Serial Old 收集器(串行)

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

4. Parallel Scavenge 收集器(吞吐量优先)

新生代采用标记-复制算法,老年代采用标记-整理算法。
也是使用多线程收集器。
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。

5. Parallel Old 收集器(吞吐量优先)

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

6. CMS 收集器(响应时间优先)

CMS 收集器

CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。整个过程分为四个步骤:

  • 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  • 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
  • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  • 并发清除:不需要停顿。

主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感;
  • 无法处理浮动垃圾;
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

7. G1 收集器

一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

类的生命周期

类的生命周期

类加载过程

包含了加载、验证、准备、解析和初始化这 5 个阶段。

1. 加载

加载过程完成以下三件事:

  • 通过类的完全限定名称获取定义该类的二进制字节流。
  • 将该字节流表示的静态存储结构转换为方法区的运行时存储结构。
  • 在内存中生成一个代表该类的Class对象,作为方法区中该类各种数据的访问入口。

2. 验证

确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

3. 准备

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

实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。

初始值一般为 0 值:

4. 解析

将常量池的符号引用替换为直接引用的过程。
其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。

5. 初始化

初始化阶段是执行初始化方法<clinit> ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。

类加载器

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader

  1. BootstrapClassLoader(启动类加载器) :最顶层的加载类,由 C++实现,负责加载%JAVA_HOME%/lib目录下的 jar 包和类或者被-Xbootclasspath参数指定的路径中的所有类。
  2. ExtensionClassLoader(扩展类加载器) :主要负责加载%JRE_HOME%/lib/ext目录下的 jar 包和类,或被java.ext.dirs系统变量所指定的路径下的 jar 包。
  3. AppClassLoader(应用程序类加载器) :面向用户的加载器,负责加载当前应用classpath下的所有 jar 包和类。

Q.E.D.