JVM

简介

  • JVM:是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。
  • 优点:
    • 一次编写,到处运行;
    • 自动内存管理,垃圾回收机制;
    • 数组下标越界检查;

运行数据时区域

  • Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
  • 线程共享:堆(包括字符串常量池),方法区(包括运行时方法常量池),直接内存 (非运行时数据区的一部分)
  • 线程私有:虚拟机栈,本地方法栈,程序计数器

程序计数器

  • 程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
  • 为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为 线程私有 的内存。
  • 作用:
    • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
    • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
  • 程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

Java虚拟机栈

  • 虚拟机栈为虚拟机执行方法而服务,描述的是Java方法执行的线程内存模型。

  • 虚拟机栈由一个个栈帧组成,而每个栈帧中都拥有:

    • 局部变量表:主要存放了编译期可知的各种数据类型(int,char,...)和对象引用;
    • 操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果;还有计算过程中产生的临时变量;
    • 动态链接:主要服务一个方法需要调用其他方法的场景。
    • 方法返回地址:存储方法的返回地址。
  • 每一次方法调用时都会有对应的栈帧被压入栈中,方法调用结束后,对应的栈帧会被弹出。

  • Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

  • 如果线程请求的栈深度大于虚拟机允许的深度,会抛出StackOverFlowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError

本地方法栈

  • Native Method:就是一个 Java 调用非 Java 代码的接口,即该方法并非由java实现。

  • 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

  • 本地方法栈在方法执行时与虚拟机栈相同。

  • 与虚拟机栈一样,本地方法栈在栈深度溢出或无法申请到足够空间的时候抛出StackOverFlowErrorOutOfMemoryError

  • Java 虚拟机所管理的内存中最大的一块,Java 堆是所有 线程共享 的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
  • Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)
  • 如果Java堆中没有没有内存完成实例分配,并且堆也无法再扩展时,虚拟机将会抛出OutOfMemoryError

方法区

  • 方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。

  • 方法区存储的是和类相关的信息,例如类的成员变量,方法参数,成员方法和构造器,以及运行时常量池。

  • 方法区常用参数:

    1
    2
    -XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
    -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
  • 如果方法区无法满足新的内存分配需求时,将会抛出OutOfMemoryError异常。

运行时常量池

  • 运行时常量池是方法区的一部分。
  • Class文件中的常量池表(用于存放编译器生成的各种字面变量与符号引用),将在类加载后存放到方法区的运行时常量池中。
  • 当常量池无法申请到内存时会抛出OutOfMemoryError异常。

直接内存

  • 直接内存并不是虚拟机运行时数据区的一部分,而是属于操作系统的内存。
  • 直接内存分配不会受到java堆大小的控制,如果各个内存之和超过了本机总物理内存限制,也会导致OutOfMemoryError异常。

垃圾回收机制

堆空间结构

  • 在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

    1. 新生代内存(Young Generation):EdenS0S1
    2. 老生代(Old Generation):Tenured
    3. 永久代(Permanent Generation):PermGen
  • JDK 8 版本之后为:

    1. 新生代内存(Young Generation)
    2. 老生代(Old Generation)
    3. 元空间(Metaspace

内存分配和回收原则

  • 堆内存分配:

    • 年轻代(1/3)
      • 伊甸园区(8/10)
      • S0(1/10)
      • S1(1/10)
    • 老年代(2/3)
  • 新创建的对象优先在Eden区分配;

  • 当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

  • 如果对象在经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。

  • 对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。

  • 针对 HotSpot VM 的实现,GC方式分为两大类:

    部分收集 (Partial GC):

    • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
    • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
    • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

    整堆收集 (Full GC):收集整个 Java 堆和方法区。

判断哪些对象需要回收

  • 引用计数法:每个对象都有一个引用计数器,每次该对象被引用时,引用计数都会 +1;离开作用域或引用失效时计数器 -1;

    对象之间循环引用的情况下两个对象的计数器都不为 0,会导致都无法被回收,可能会出现内存泄漏问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 对象objA和objB互相引用
    public class ReferenceCountingGc {
    Object instance = null;
    public static void main(String[] args) {
    ReferenceCountingGc objA = new ReferenceCountingGc();
    ReferenceCountingGc objB = new ReferenceCountingGc();
    objA.instance = objB;
    objB.instance = objA;
    objA = null;
    objB = null;
    }
    }
  • 可达性算法分析

    • GC roots 对象作为起点,从这些结点开始乡下搜索引用的对象,找到的对象都标记为非垃圾对象,找不到的为垃圾对象。
    • 哪些对象可以作为GC roots
      • 虚拟机栈(栈帧中的本地变量表)中引用的对象;
      • 本地方法栈(Native 方法)中引用的对象;
      • 方法区中类静态属性引用的对象;
      • 方法区中常量引用的对象;
      • 所有被同步锁持有的对象;
      • JNI(Java Native Interface)引用的对象;
  • 引用类型

    • 强引用StrongReference):我们使用的绝大部分都是强引用,如果一个对象具有强引用,那么绝对不会被垃圾回收器回收,即使是内存空间不足(会直接抛出OOM)。
    • 软引用SoftReference):如果一个对象只具有软引用,那就类似于可有可无的。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。
    • 弱引用WeakReference):如果一个对象只具有弱引用,在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
    • 虚引用PhantomReference):顾名思义,就是形同虚设。虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用主要用来跟踪对象被垃圾回收的活动

垃圾回收算法

  • 标记-清除:该算法分为标记和清除两个阶段,首先标记出所有需要回收的对象,然后进行清除。(也可以反过来,先标记处所有存活的对象,统一回收未被标记的对象)

    该算法的缺点是:

    • 执行效率不稳定:如果java堆中大部分对象都是需要被回收的,那么就需要大量的标记和清除动作。

    • 会产生内存碎片(标记清除后未整理空间),可能在之后分配大对象时无法找到合适的连续空间,容易导致频繁的内存分配和回收。

  • 复制收集:该算法将可用内存空间分为两部分,每次只使用其中一部分。当一部分内存用完后,将未被回收的对象复制到另一部分内存中,并清除原来的内存。该算法的优点是简单高效,缺点是需要额外的内存空间。如果存活对象的数量较大的话,复制性能会变得很差。

  • 标记-整理:根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。但是整理需要线程同步,因此效率偏低。(整理的时候要停止丢垃圾)

分代收集

  • 当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为 新生代老年代,根据各个年代的特点选择合适的垃圾收集算法。
  • 比如在 **新生代 **中,每次收集都会有大量对象死去,所以可以选择 ”标记-复制“ 算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
  • 老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择 **“标记-清除” ** 或 **“标记-整理” **算法进行垃圾收集。

垃圾收集器

  • 垃圾回收算法是内存回收的方法论,垃圾收集器是内存回收的具体实现。

  • Serial(串行收集器):单线程GC,进行垃圾收集工作时必须要暂停其他所有的线程(STW),直到收集工作结束。新生代采用标记-复制算法,老年代采用标记-整理算法。

  • ParNew 收集器:其实就是 Serial 多线程版本,使用多线程进行垃圾回收。新生代采用标记-复制算法,老年代采用标记-整理算法。

  • Parallel Scavenge 收集器:Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量。新生代采用标记-复制算法,老年代采用标记-整理算法。这是 JDK1.8 默认收集器

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

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

  • CMS收集器(Concurrent Mark Swap):CMS收集器是一种以获取最短回收停顿时间为目标的收集器。CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

    CMS算法基于 “标记-清除” 算法,整个过程分为四个部分:

    • 初始标记:暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
    • 并发标记:同时开启 GC 和用户线程,记录引用可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
    • 重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
    • 并发清除:开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

    CMS的缺点:

    • 无法处理浮动垃圾(影响不大,浮动垃圾在下一次垃圾收集时会被清理掉);
    • 使用 “标记-清除” 算法会产生大量空间碎片。
  • G1收集器(Garbage-First):是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。

    G1收集器的运作过程大致分为四个部分:

    • 初始标记:标记 GC Roots 能够直接关联到的对象,时间很短。
    • 并发标记:从GC Roots开始对堆中的对象进行可达性分析,递归扫描整个堆里的对象图,找出所有要回收的对象。这个阶段时间较长,但是可以与用户程序并发执行。对象图扫描完成后,可能还会有一些并发时期引用改动的对象。
    • 最终标记:对用户线程做一个短暂的暂停,用于处理并发阶段遗留的少量的引用变动的对象。
    • 筛选回归:G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region。
  • ZGC收集器:与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。在 ZGC 中出现 Stop The World 的情况会更少!

类文件结构

  • JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。不同的语言被编译成字节码,最终运行在Java虚拟机上。

类加载过程

类的生命周期

  • 类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段::加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。

类加载过程

  • 系统加载Class类型文件主要是三步:加载 -> 连接 ->初始化。连接过程包括验证,准备,解析。

  • 加载:这一步通过类加载器完成,主要完成以下事情。

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

    当我们要加载一个类时,具体要使用哪个类加载器由双亲委派模型决定。

  • 验证:确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

    验证阶段分为以下四步:

    • 文件格式验证(Class 文件格式检查)
    • 元数据验证(字节码语义检查)
    • 字节码验证(程序语义检查)
    • 符号引用验证(类的正确性检查)
  • 准备:准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

    tip:

    • 这个阶段内存分配仅包括类变量(不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中)。
    • 类变量所使用的内存都应当在 方法区 中进行分配。
    • 这里的初始值都是设置的数据类型默认的零值。比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111。
  • 解析:解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。也就是得到类或者字段、方法在内存中的指针或者偏移量,后续可以直接调用相应方法。

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

  • 类卸载:卸载类即该类的 Class 对象被 GC。

    卸载类需要满足 3 个要求:

    1. 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
    2. 该类没有在其他任何地方被引用
    3. 该类的类加载器的实例已被 GC

类加载器

简介

  • 类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。

  • 类加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的“类文件”。

  • 每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。

类加载器加载规则

  • JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。
  • 对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。
  • 每一个类加载器,都拥有一个独立的类名称空间。 即两个不同的类加载器加载同一个Class文件,得到两个类必定不相等。

ClassLoader

  • JVM 中内置了三个重要的 ClassLoader

    1. BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库%JAVA_HOME%/lib目录下的 rt.jarresources.jarcharsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。
    2. ExtensionClassLoader(扩展类加载器):主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。
    3. AppClassLoader(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。如果应用程序没有自定义过自己的类加载器,那么一般情况下这个就是默认加载器。

    除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。

    双亲委派模型

  • 各个类加载器之间的层次关系被称为 双亲委派模型。双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应该有自己的父类加载器。

  • 双亲委派模型的工作流程

    • 如果一个类加载器接收到了类加载的请求,他会先把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的类加载请求都应该传送到顶层的启动类加载器中,只有当父亲加载器反馈自己无法完成这个加载请求时(它的搜索范围中没有找到所需的类),子加载器才会尝试自己去完成加载。
  • 双亲委派模型的好处

    • **Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。**例如java.lang.Object,它存放在rt.jar里,无论哪一个类加载器要加载这个类,最终都是委派给最顶端的启动类加载器进行加载,因此Object类在各种类加载环境中都能保证是同一个类。
    • 双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载,也保证了 Java 的核心 API 不被篡改。