JVM

重点内容

  • 类加载机制
  • 运行时数据区域
  • 字节码执行机制
  • GC垃圾回收
  • JVM内存模型

一、类加载机制

​ 类的生命周期:加载、验证、准备、解析、初始化、使用、卸载

1.1 类加载的时机

​ 有且只有这几种情况必须对类进行加载:

(1) 遇到new、getstatic、putstatic或invokestatic四条指令时,会生成四条指令的场景:

  • 使用new实例化对象时

  • 读取或设置一个静态字段(final除外)

  • 调用静态方法

(2) 对类型反射调用,没有被初始化就进行初始化

(3) 初始化子类时,发现父类还未初始化

(4) 虚拟机启动时,要执行的主类会被初始化

上述为主动引用,而其他方式都是被动引用,不会触发初始化。

例如:子类调用父类静态字段、数组定义形式引用类、调用类的final静态字段则不会触发本类的初始化。

1.2 类加载过程

  • 加载

(1) 通过全限定类名获得此类的二进制字节流。(类加载器实现)

(2) 将字节流所代表的静态存储结构转化为方法区的运行时数据结构

(3) 在内存中生成一个代表该类的Class对象

加载完成后,class文件即按照JVM设定的格式存储在方法区了。

  • 验证

    作用:防止恶意攻击,不会危害虚拟机自身安全。

(1) 文件格式验证:验证字节流是否符合class文件格式规范(唯一基于字节流的验证,结束后字节流内容进入内存)。

(2) 元数据验证:语义分析。保证其描述的信息符合java语言规范的要求。

(3) 字节码验证:通过数据流和控制流验证语义是否合法,保证被校验类的方法不会危害虚拟机安全。

(4) 符号引用验证:保证类中的引用都能正常访问。

  • 准备

方法区static变量(类变量)分配内存并赋予初始值(零值)

  • 解析

常量池中的符号引用替换为直接引用

  • 初始化

本质就是:即执行()方法(构造器方法),区别于构造方法

  • 方法是由编译器自动收集的所有static变量和static块中的语句合并而成。

  • 不需要显式的调用父类构造器,因为jvm能保证在子类初始化前,父类已初始化结束。

如果一个类没有static变量赋值操作和static块,可以不生成。接口的初始化不需要事先初始化其父接口。其实现类也同理。

1.3 类加载器

  • 启动类加载器(Bootstrap Class Loader):加载存放在\lib目录下的,并能被JVM识别的类库。

    主要是加载核心类库(java,javax,sun),所以自定义获取时为null(没有权限获取)。

  • 扩展类加载器(Extension Class Loader):加载\lib\ext和java.ext.dirs系统变量所指定的目录路径中的所有类库。用于实现java类库的扩展。

  • 应用程序类加载器(Application Class Loader):也叫系统类加载器,加载应用程序中的类。自定义默认使用的类加载器。

判断两个类是否相等 要在两个类由同一个ClassLoader加载完成的条件下讨论才有意义。

即两个类相等 <=> 包名相同 + 同一类加载器加载

1.4 双亲委派机制

​ 要求除了顶层的启动类加载器之外,其余的类加载器都应有自己的“父类”加载器。这里的父子关系一般是用组合实现(等级或包含关系不是继承关系)的。

工作过程:

​ 一个类加载器收到一个类加载的请求,会把请求向父类加载器委派,每一层次都是如此,所以最终所有类加载请求都会委派到启动类加载器。当父类加载器搜索不到相应类时,才会反馈自己无法完成请求,子类加载器才会尝试加载。

作用:

  • 保证同一层级的类都由同一类加载器加载,保证了基础类型的一致性。
  • 保护程序的安全,防止核心API被随缘篡改。

二、JAVA内存区域与内存溢出异常

2.1 Java运行时数据区域

2.1.1 程序计数器

​ PC寄存器主要作为当前线程所执行的字节码的行号指示器

​ 类似于x86的IP寄存器。字节解释器工作时通过改变该计数器的值来选取下一条需要执行的指令

​ 分支、循环、跳转、异常处理、线程恢复等都需依赖该计数器。若正在执行的是java方法,则该计数器记录的是正在执行的字节码的地址。若正在执行的是本地(Native)方法,该计数器值为空。

​ 唯一一个没有规定OutOfMemoryError的内存区域。

2.1.2 虚拟机栈

​ 每一个Java方法被执行时,jvm都会创建一个栈帧(Stack Frame),来用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法从被调用直至执行完毕,对应一个栈帧在虚拟机栈中从入栈到出栈。

​ 局部变量表:用于存放方法参数和方法内部定义的局部变量。所需的内存空间在编译期间完成分配。

  • 局部变量表存放了编译期可知的 基本数据类型、对象引用(reference类型)、returnAddress类型(指向字节码的地址)
  • 局部变量表空间的最小单位——局部变量槽(Slot)。Slot是32位,所以long、double占两个slot,其余占一个。

​ 操作数栈:

内存溢出异常:

  1. 当线程请求的栈深度大于虚拟机允许的深度,抛出StackOverflowError异常(栈溢出)。

  2. 若虚拟机栈容量可以动态扩展,当栈扩展到无法申请到足够内存时抛出OutOfMemory异常(内存溢出)。

2.1.3 本地方法栈

​ 与虚拟机栈类似。虚拟机栈为java方法提供服务,本地方法栈为本地方法提供服务。

2.1.4 堆

存放对象实例和字符串常量池、数组。几乎所有对象实例都在这里分配内存。

在这里插入图片描述

​ Java堆 = 新生代 + 老年代 + TLAB(每个线程私有的分配缓冲区)

​ 虚拟机所管理的内存中最大的一块。在虚拟机启动时创建。是垃圾收集器管理的内存区域,因此也被称作GC堆。

  • 从回收内存的角度来看,可能包含“新生代”、“老生代”、“永久代”等。

  • 从分配内存的角度来看,包含多个线程私有的分配缓冲区(TLAB),以提升对象分配时的效率。将java堆细分的目的只是为了更好的回收内存,或更快的分配内存。

    对象分配时,TLAB作为内存分配首选,一旦对象在TLAB上分配失败就就会通过加锁(堆内存是线程共享)在eden区分配内存。

  • 处于物理上不连续的内存空间中,但在逻辑上应该视为连续的。
  • 可以是固定大小,也可以是可扩展的。当堆中没有内存完成实例分配,且堆也无法扩展时,抛出OOM异常。
2.1.4.1 Java堆与JVM栈的关系:
1. JVM栈中的引用(主要是局部变量,方法参数)指向Java堆中的对象实例。     
2. 方法执行完毕以后,只是JVM栈中的引用消失,GC时才会对Java堆中的对象进行回收。
2.1.4.2 对象栈上分配

​ 逃逸分析:分析对象动态作用域。

  • 当一个对象在方法中定义后,对象只在方法内部使用,则认为没有发生逃逸。

  • 当一个对象在方法中定义后,对象被外部方法引用,则认为发生逃逸。

    对象能否被分配到栈,需要使用逃逸分析手段,可以减轻堆内存分配的压力。

如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配,这样就不需要在堆上分配空间,也不用进行GC。

2.1.5 方法区

​ 又可以叫做元空间 (在JDK1.8之前,是永久代),但是不准确。

存储已被虚拟机类加载后的类信息、常量(运行时常量池)、静态变量、即时编译器编译后的代码缓存、运行时常量池等数据。

在这里插入图片描述

​ 可以选择不实现垃圾收集。该区域的内存回收目标主要是针对常量池的回收和对类型的卸载。一般回收效果较难令人满意。

​ 方法区无法满足新的内存分配需求时,抛出OOM异常。k

运行时常量池:方法区的一部分。Class文件中的常量池表(存放编译期生成的各种字面量与符号引用)在类加载后存放到方法区的运行时常量池中。运行期间也可以将新的常量放入运行时常量池。

2.1.5.1 方法区、堆、JVM栈之间的交互关系:

如上图一条Java新建对象实例语句:

  1. 第一个Person是变量类型,所以存放在方法区(变量的类型,类的信息,等等)中
  2. 第二个person是局部变量引用,在一个方法中写的,所以放在JVM栈中
  3. 第三个new Person();是一个实例化对象,所以放在Java堆中

JVM栈中为局部变量的引用,实际上是指向Java堆中的对象实例,然后对象实例又指向方法区中的对象类型。

2.1.6 直接内存

并非虚拟机运行时数据区的一部分。

​ NIO类引入了一种基于通道和缓冲区的IO方式,它使用Native函数库直接分配堆外内存。然后通过java堆内的DirectByteBuffer对象作为这块内存的引用进行操作。避免了在Java堆和Native堆之间来回复制数据,在一些场景提升了性能。

​ 受物理和操作系统级内存限制,存在OOM异常。

2.2 对象的创建、内存布局与访问

​ 以HotSpot为例

2.2.1 对象的创建

2.2.1.1 对象创建的方式

​ 主要分为五种方式创建:

1. new:最常见的方式,变形有`调用类的静态方法`和`类的建造者和工厂方法来实例化`
 2. 反射:一种是Class的newInstance(),调用类的空参构造器,权限是public;一种是Constructor的newInstance(XXX类),可以掉空参和带参的构造器,权限没有要求。
 3. clone()方法:用一个类来克隆,前提是类实现了Cloneable接口
 4. 反序列化:从文件、网络中获取一个对象的二进制流来进行反序列化成对象
 5. 第三方库Objenesls
2.2.1.2 对象创建的步骤

①寻找符号引用:遇到new指令,检查该指令的参数是否能在常量池中定位到一个类的符号引用

②类加载检查:该符号引用代表的类是否已被加载、链接和初始化。如果没有,先执行类加载过程(双亲委派机制)。

③内存分配:对象所需内存的大小在类加载完成后便可完全确定。

内存分配的两种方式:“指针碰撞”、“空闲列表”。

  • 指针碰撞:若java堆中的内存都是绝对规整的。使用过的放在一边,空闲的放在另一边。分配内存即是将指针向空闲方向挪动与对象等大小的一段距离。
  • 空闲列表:若java堆中的内存并不规整,碎片化。使用过的内存和空闲内存交错在一起。虚拟机必须维护一个空闲列表,记录哪些内存块是可用的。分配内存时找到一块足够大的空间划分给对象实例。并更新表的记录。

Java堆是否规整由GC是否带有空间压缩整理能力决定。

​ 并发情况下内存分配安全策略:

​ (1)对分配空间动作进行同步处理,通过CAS配上失败重试方式,进行区域加锁保证更新操作的原子性。

CAS(Compare and Swap):通过比较预期数值和新值,若相等,内存位置替换为新值。若不相等,无操作。

​ (2)每个线程在java堆中预先分配一小块线程私有的TLAB,用于该线程的内存分配。本地缓冲区(TLAB)用完了,需要分配新的缓冲区时才需要同步锁定。

④内存初始化:虚拟机将分配到的空间都初始化为0值(对象头除外),区别于类加载过程中对类进行初始化。若使用了TLAB,该工作也可以提前至TLAB分配时执行。

⑤对象的必要信息存入对象头中。所以对象头指向的是方法区中类的信息。

⑥执行()方法:即该类的构造函数

2.2.2 对象的内存布局

​ 对象的存储布局划分为三个部分:对象头、实例数据、对齐填充

​ 对象头:包括两类信息。

  • 第一类用于存储运行时元数据(哈希值、GC分代年龄、锁状态标志等)(Mark Word)。
  • 第二类是类型指针,即对象指向类型元数据的指针(并非所有虚拟机实现都必须在对象数据中保留类型指针)。
  • 若该对象为数组,对象头中还需记录数组长度

​ 实例数据:对象真正存储的有效信息,各种被定义类型的字段。(包括从父类那继承下来的)

  • 分配顺序为宽度由长到短。相同宽度的字段被分配到一起存放父类在前,子类在后。子类中较窄的变量也允许插入父类变量的空隙中。

​ 对齐填充:占位符的作用。HotSpot的自动内存管理系统要求对象的大小必须是8字节的整数倍。对齐填充位用来补全到8字节整数倍。

例子:以Customer cust = new Customer();

说明:Customer里面有int string属性 和 一个Account对象

2.2.3 对象的访问定位

​ 对象的访问定位方式有两种:句柄访问直接指针访问

​ 句柄访问:

  • 实现:在堆中维护一个句柄池。句柄中包含对象实例数据与类型数据的地址信息。Reference存储该对象句柄地址。
  • 优点:稳定,在对象被移动时(GC时普遍发生),reference本身无需更改

​ 直接指针访问:

  • 直接指针访问:reference存储对象实例数据的地址。对象头中包含类型数据的地址。
  • 优点:速度快,节省了一次指针定位的时间开销

2.3 OutOfMemoryError异常(OOM异常)

​ OutOfMemoryError异常:内存溢出异常 ,简称OOM异常。

2.4 内存溢出与内存泄露(面试)

  • 内存溢出(OOM)

    ​ 没有空闲内存,并且垃圾收集器GC后也无法提供更多的内存了,会尝试回收软引用对象

    原因:(1)堆内存设置的不够:可能有内存泄露问题,设置大小不合理,用-Xms和-Xmx来调整。

    ​ (2)创建大量的大对象,并且没有被回收。

  • 内存泄露

    严格来说,只有对象不会再被程序用到,并且GC不会回收到它们,才叫做内存泄露。

    宽泛讲:对象的生命周期变得很长(比如局部变量,设置成Static变量)。

举例(面试):

  1. 单例模式:

    单例对象的生命周期同应用程序一样长,如果它有外部引用的话,则外部对象不会被回收,则导致内存泄露。

  2. 一些提供了close()的资源未使用close():

    例如:数据库连接,使用dataSource.getConnection(),网络连接Socket 和 IO流未手动close(),否则都无法回收。

三、垃圾收集器与内存分配策略

​ 关于GC:JVM自动进行内存分配与垃圾收集,这样就会降低内存泄露和内存溢出的风险

  1. 什么是垃圾?

    ​ 运行程序中没有任何指针指向的对象

  2. 为什么要GC?

    • 如果不进行GC,内存迟早要消耗殆尽,导致内存溢出
    • 释放没用的对象,对内存进行碎片化整理,以便JVM将整理出的内存分配给以后新对象
    • 随着应用程序越来越大,业务越来越多,不进行GC不能保证应用程序的正常运行

3.1 补充:执行引擎

Java是半编译半解释型语言:现在JVM在执行Java代码的时候,通常会将解释执行和编译执行二者结合起来进行。

执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令

某些被频繁执行的方法或者代码块,会被JVM认定为“热点代码”。在运行时JVM会把这些热点代码编译成与本地平台相关的机器码,并且进行各种层次的优化,以提高执行效率。完成这个任务的编译器称为即时编译器(JIT编译器)。

3.2 标记阶段(对象是否存活)

3.2.1 引用计数算法(不用)

​ 在对象中添加一个引用计数器。每当一个地方引用它,计数器值+1。引用失效时,计数器值-1。计数器值为0时,对象就不会再被使用的。

​ 优点:原理简单,判断效率高。

​ 缺点:每次都要操作计数器,时间开销大;无法解决循环引用的问题(两个对象相互引用,不会被回收),造成内存泄露。导致Java垃圾收集器没有使用这个标记算法。

3.2.2 可达性分析算法(JVM使用)

​ 通过一系列称为GC Roots的根对象集合,作为起始节点集。从这些节点开始,根据引用关系向下搜索。若从GC Roots到某个对象不可达,则该对象可回收。

在这里插入图片描述

作为GC Roots对象的条件:必须活跃的引用,例如

(1) 在虚拟机栈中有引用的对象(参数、局部变量等)(栈帧出栈后不再作为GC Roots)

(2) 本地方法栈中本地方法引用的对象

(3) 方法区中的类静态属性引用的对象(即static的类变量)

(4) 方法区中常量引用的对象(即final变量)

(5) 虚拟机内部的引用(基本数据类型对应的Class对象、常驻的异常对象、类加载器等)

(6) 被同步锁持有的对象(synchronized关键字)

(7) 反应java虚拟机内部情况的JMXBean、本地代码缓存等。

(8) 根据垃圾收集器和当前回收区域的不同,还可能临时加入其它GC Roots。

比如:分代收集和局部回收,这个区域的对象完全可能被其他区域对象所引用,例如老年代的对象引用与年轻代的对象实例。

在新生代建立一个全局数据结构“记忆集”。这个结构把老年代划分为若干小块,标识出哪一块会出现跨代引用。此后发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入GC Roots进行扫描。该方法需要在对象改变引用关系时维护记录数据的正确性。

3.2.3 引用

强引用:最传统的引用定义。任何情况下,只要强引用关系存在,GC永远不会回收掉被引用对象。

软引用:还有用,但并非必须的对象。在系统即将发生内存溢出异常前,会把软引用关联对象列入回收范围进行第二次回收。

弱引用:强度比软引用更弱。下一次垃圾收集发生时,无论内存是否足够,都进行回收。

虚引用:最弱的引用关系。虚引用的唯一作用就是在该对象被GC回收时收到一个通知。

3.2.4 自救(不使用)

​ finalize()方法:类似C++析构函数,对象回收之前被JVM垃圾收集器调用,只会被调用一次

​ 主要用于:

  • 对象被回收前进行资源的释放(类似遗嘱)。
  • 自救行为:只要重新与引用链上的任何一个对象建立关联即可。

​ 一个对象拥有两次标记的对象才会被回收。

​ 当GC Roots判定为不可达时,对象被进行第一次标记。随后筛选是否有必要执行finalize()方法(或已执行过)。

  • 没有finalize()方法(或已执行)的对象会进行第二次标记。

  • 拥有finalize()方法并未被执行的,会被放置在F-Queue队列中。并由虚拟机创建一条低优先级的Finalizer线程执行它们的finalize()方法。虚拟机会“触发”该finalize()方法的开始,但并不承诺会等待其结束(为了避免某个对象的finalize()方法执行缓慢或陷入死循环,导致GC崩溃)。

    若对象在finalize()方法中与引用链中的对象或类成功建立起了联系,就会逃脱第二次标记,被移出即将回收集合。(但一个对象的finalize()方法只会被执行一次,第二次回收时将不会再执行其finalize()方法)

建议不使用该方法,不管是自救还是释放资源,他的代价高昂,不确定性太大,就算是释放资源,try-finally或者其他方式会做的更好。

3.2.5 回收方法区

​ 方法区的回收并非java虚拟机必须实现的功能。

方法区回收主要是:废弃常量和不再使用的类型。

  1. 常量的回收类似于堆中对象的回收。如果一个常量曾经进入了常量池,当前系统及虚拟机中又没有任何引用该常量,即可回收。

  2. 类型可回收的条件:

(1) 堆中不存在该类及其派生子类的实例。

(2) 该类的类加载器已被回收。

(3) 该类对应的java.lang.Class对象没有在任何地方被引用

3.3 垃圾收集算法

3.3.1 标记-清除算法

​ 最基础的GC算法。即标记可回收对象,再回收标记对象,需要维持空闲列表。

在这里插入图片描述

缺点:

(1)执行效率不稳定,标记和清除过程可能耗时过长。

(2)内存空间碎片化

3.3.2 复制算法

​ 半区复制算法:将可用内存划分为大小相等的两块。每次只用其中一块。当这一块内存用完时,将仍存活的对象复制到另一块上。然后把已使用过的内存块一次清除,对象分配空间为指针碰撞。

在这里插入图片描述

Appel式回收:将新生代划分为一块较大的内存(Eden)和两块较小的内存(Survivor)。Eden和Survivor默认大小比8:1。

每次分配内存只使用Eden和一块Survivor,发生垃圾收集时,将存活的对象复制到另一块Survivor中,清除Eden和使用过的Survivor。当Survivor中的空间不足以容纳一次Minor GC的存活对象时,需要依赖老年代区域进行分配担保(即多余的存活对象进入老年代区)。

优缺点:

(1)简单、运行高效,复制过去保证了连续性,不会出现“碎片问题”。

(2)需要两倍空间,需要维护引用与对象之间的关系,不管是内存占用和时间开销都比较大

适用于存活对象较少的情况,例如老年代。

3.3.3 标记-整理算法

​ 针对老年代对象的回收算法。将存活的对象向内存一端移动,直接清理掉边界以外的内存。

在这里插入图片描述

​ 优缺点:

(1)不用内存减半的代价,JVM只需维持一个内存的起始地址即可,存活对象分配使用指针碰撞。

(2)移动大量存活对象带来极大的负重,需要全程暂停用户应用程序。

3.3.4 分代收集理论

​ 当前大多数商业虚拟机的GC,大都遵循“分代收集”设计。

​ 设计原则:GC应将java堆划分出不同区域,把java堆划分为新生代和老年代两个区域。

  • 年轻代:对象比较短,生命周期短,使用复制算法比较好(Eden Survivor0 复制到 Survivor1)
  • 老年代:使用标记-清除-整理结合,平时使用标记-清除算法,当内存碎片化过于严重,以致影响对象分配时,使用一次标记-整理算法消除碎片化空间。

3.4 HotSpot算法细节实现

3.4.1 根节点枚举

枚举根节点时,必须暂停所有线程。即GC时,暂停所以线程(STW)。

​ 准确式垃圾收集:HotSpot使用OopMap保存并区分一个地址存储的是数据还是引用。OopMap协助完成根节点枚举。

3.4.2 安全点

​ 判断依据:引用关系不会发生变化,程序能够长时间执行,这些时候才能是安全点。

​ 并非每一次引用变化都会生成OopMap。只在特殊的位置生成OopMap。这些位置被称为“安全点”。所以用户程序并非任何时刻都能够进行STW和垃圾收集,只有在安全点才可以。一般安全点出现在指令序列的复用时(循环、方法调用、异常跳转等)。

​ 多线程状态下,安全点的寻找有抢先式中断和主动式中断两种方式。

  • 抢先式中断(没有虚拟机使用):垃圾收集发生时,中断所有线程。此时没有停在安全点上的恢复执行,过段时间再中断,直到停到安全点上为止。
  • 主动式中断:需要垃圾收集时,设置一个标志位。线程运行时每次经过轮询点都会轮询标志位。发现修改即停止。(轮询点=安全点+需要分配堆内存时)

​ HotSpot使用内存保护陷阱的方式解决轮询操作。设置标志位操作=置xxx内存区不可读,轮询操作=读xxx内存区。轮询读到不可读时会进入异常,异常处理器中挂起该线程。

3.4.3 安全区域

​ 安全区域相当于拉伸了的安全点。在安全区域中,能够确保引用关系不会发生变化。

​ 安全区域的使用是为了防止在使用安全点时发生一个线程一直分配不到处理器以致无法运行至安全点,比如Sleep状态和Blocked状态,无法响应中断请求。

​ 用户线程执行到安全区域中时,会标明自己进入了安全区域。GC在进行垃圾收集时可以忽略这些线程。当用户线程离开安全区域时会检查是否在进行根节点枚举(或者说是GC),若在进行根节点枚举(GC),则线程等待枚举完成。

3.4.4 记忆集与卡表

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

​ 记录表有三种记录精度:

字长精度:每个精度精确到一个机器字长。该字包含跨代指针。

对象精度:每个记录精确到一个对象。该对象里有字段含有跨代指针。

卡精度:每个记录精确到一块内存区域。该区域内有对象含有跨代指针。

​ 卡精度,即使用“卡表”实现记忆集。是最常用的记忆集实现方式。卡表是一个字节数组,数组的每一个元素对应着一个卡页(类似内存页)。一个卡页一般2^N大小。卡表的下标代表卡页的标号。当前元素置1称为变脏。垃圾收集发生时,只需找出变脏的元素,并将该卡页加入GC Roots进行扫描。

3.4.5 并发的可达性分析

三色标记:

白色:未被GC访问过的对象

黑色:已被GC访问过,且其所有直接引用都已被访问过的对象

灰色:已被GC访问过,但其仍有直接引用未被访问

仅当以下两个条件同时满足时,会产生“对象消失”问题:

  1. 赋值器插入了一条或多条黑色对象到白色对象的引用

  2. 赋值器删除了灰色对象所有到该白色对象的直接或间接引用

两种解决方案:

  1. 增量更新:破坏第一个条件。当黑色对象插入一个指向新的白色对象的引用时,记录下来。并发扫描结束,以该黑色对象为根再扫描一次。

  2. 原始快照:破坏第二个条件。当灰色对象删除一个指向白色对象的引用时,记录下来。并发扫描结束,重新扫描该白色对象。

CMS采用增量更新,G1和Shenandoah采用原始快照。

3.5 经典垃圾收集器

在这里插入图片描述

  • 垃圾收集器的指标:不可能三角
  1. 吞吐量:用户线程执行时间/总执行时间(用户线程执行时间 + GC时间)
  2. 暂停时间:STW的时间
  3. 内存占用:Java堆区所占的内存大小

​ 注重吞吐量(做的事多),每次STW时间长,总时长短

​ 注重低延迟(用户交互性好,不卡),每次STW间隔短,总时长长

现在的垃圾回收器标准:在满足最大吞吐量优先的情况下,降低停顿时间。

  • 垃圾收集器的分类:除了并发的收集器都要STW,加粗的是年轻代的收集器
  1. 串行收集器:Serial,Serial Old

  2. 并行收集器:ParNewParallel Scarage,Paraller Old

  3. 并发收集器:CMS,G1(年轻代老年代都可以),垃圾回收与用户线程并发执行

  • 垃圾收集器的历史

在这里插入图片描述

3.5.1 Serial收集器

​ 默认的新生代GC,在Client模式下的收集器。收集时会暂停所有其他用户线程。

在这里插入图片描述

优点:

(1)额外内存消耗最小

(2)没有线程交互的开销,在核心数较少的处理器中效率较高(一般只用于单核处理器)。适合桌面应用或部分微服务场景。

缺点:垃圾收集时会有停顿。

3.5.2 ParNew收集器(与CMS组合)

​ 实质上是Serial收集器的多线程并行版本

在这里插入图片描述

ParNew与CMS是默认组合。可以认为ParNew收集器已经并入CMS收集器,成为其专门收集新生代的部分。

​ ParNew收集器较Serial在多核cpu上效率更高。

3.5.3 Parallel Scavenge收集器

​ Parallel Scavenge收集器类似于ParNew,同为标记-复制算法、并行收集。

不同点在于Parallel收集器更注重于达到一个可控制的吞吐量,吞吐量优先的情况下使用。

PS收集器能更大限度分配处理器给用户线程,适合于在后台运算(例如批量处理、科学计算、业务处理),而不需要太多交互的任务。

3.5.4 Serial Old收集器

​ Serial收集器的老年代版本,采用标记-整理算法。除桌面端应用,JDK5前还可与PS收集器搭配使用,或作为CMS发生失败时的后备预案

3.5.5 Parallel Old收集器

PS收集器的老年代版本,采用标记-整理算法。与PS收集器搭配使用,适用于处理器资源紧缺、吞吐量优先的场景。

3.5.6 CMS收集器

​ CMS收集器(老年代),并发的垃圾收集器,不会STW。以最短停顿时间为目标,适合于用户交互场景

​ CMS四个阶段:

(1) 初始标记:仅仅标记GC Roots能直接可达到的对象,追求时间效率,避免用户久等。

(2) 并发标记:从GC Roots直接关联对象开始遍历整个对象图(所以可达的对象)的过程(可与用户线程并发执行

(3) 重新标记:针对并发标记过程中产生了变动的对象进行重新标记,修正并发标记的。

(4) 并发清除:标记-清除算法(可与用户线程并发执行)

初始标记和重新标记由于涉及对象很少,所以速度极快。而涉及大量对象的并发标记和并发清除阶段都不会耽误用户程序的运行。(选用标-清而非标-复正是因为标-清无需变动存活对象,可与用户程序并发)

在这里插入图片描述

​ 优点:(1)低延迟,用户体验好,利于与用户交互,并发垃圾收集。

​ 缺点:

​ (1)处理器资源敏感。特别是核心数少的处理器,因为GC占用线程,所以总吞吐量降低。

​ (2)难以应对“浮动垃圾”(即在标记完成后出现的新垃圾),因为有并发标记的阶段,所以这阶段产生的垃圾不会被标记,这些垃圾只能等到下一次垃圾收集时清理。

​ 同时,CMS不能等到老年代几乎填满后再启动,因为他必须留下足够的内存,在垃圾收集的同时给用户线程使用。预留过多会造成内存浪费以及回收频繁;预留过少会造成“并发失败”,并发失败发生后虚拟机会调用Serial Old重新收集,会使性能下降。

​ (3)由于标记-清除算法,而产生的空间碎片化问题,甚至可能会进行一次Full GC。

3.5.7 G1收集器

​ G1是一款面向服务端的GC。在JDK8U40彻底完善已替代PS组合,并在JDK9中替代CMS组合。

  • 特点:既兼顾低延迟,也针对大吞吐量。

​ G1在组建回收集时,不再根据新生代老年代衡量。而是把java堆划分为多个大小相等的Region,每个Region根据需要可以扮演新生代的Eden、Survivor区域或老年代区域。收集器再对不同Region采用不同策略进行收集。Humongous区域专门用来存放大对象(即超过Region一半大小的对象)。对于超过整个Region的对象,用多个连续Humongous存储。G1大多数行为将其视为老年代。

​ G1仍保有新生代老年代概念,但不划分固定区域,而是动态集合。G1将Region作为最小回收单元,因此停顿时间可预测。通过跟踪每个Region的回收价值大小(即回收所获得空间和回收所需时间的经验值),并维护一个优先级列表。回收时根据用户设定允许的回收停顿时间,优先处理价值高的Region。

​ G1流程:

(1) 初始标记:同CMS

(2) 并发标记:同CMS

(3) 重新标记:同CMS

(4) 筛选回收:局部可看做复制算法,将回收集中的Region中的存活对象复制到空Region,再清理回收集中的Region,整体上是标记-压缩算法。(涉及存活对象,需暂停用户线程)

在这里插入图片描述

​ 相较于CMS:

​ 优点:

​ (1)可指定最大停顿时间。

​ (2)没有内存碎片产生,有利于程序长时间运行。

​ 缺点:卡表占用过多的内存(10%-20%),并且执行负载更高。

​ 总结:小内存用CMS,大内存用G1。

3.6 低延迟GC

GC的三项指标:内存占用、吞吐量、延迟。

​ 硬件性能的增长,导致内存占用不再如此重要,吞吐量也随硬件上升,而延迟也由于内存的增加而增加。

​ 所以降低延迟是GC的首要目标,采取并发的手段(不用STW,减少延迟,初始标记才需要STW,即标记GC ROOTS)

3.6.1 Shenandoah收集器

​ Oracle JDK中未使用,相比于G1的改进:

(1)支持并发的整理算法

(2)不使用分代收集。

(3)不使用记忆集,改用连接矩阵来记录跨Region的引用。

Shenandoah流程:

(1) 初始标记:同G1。

(2) 并发标记:同G1。

(3) 最终标记:同G1。

(4) 并发清理:用于清理不存在任何存活对象的Region。

(5) 并发回收:大致同G1,使用读屏障和转发指针,使得复制存活对象的过程可以与用户线程并发。

(6) 初始引用更新:并无具体操作,实际只是确保所有回收线程都已到达该阶段。

(7) 并发引用更新:实际更新引用。

(8) 最终引用更新:修正GC Roots中的引用。

(9) 并发清理:清理回收集中所有Region。

Brooks Pointer:基于转发指针实现的复制对象与用户线程并发的解决方案。在对象头处加一个引用指针指向自己。当该对象被复制时,改变旧对象的引用指针使其指向新地址,直到Region被清除。

3.6.2 ZGC

是一款基于Region内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法。

ZGC与前代GC相比的新技术:

  1. Region:具有动态性,动态创建和销毁,以及动态的区域容量大小。分为小型中型大型。小型2MB,放置小对象;中型32MB,放置中型对象;大型为2MB的整数倍,每个大型Region仅放置一个大型对象,大型Region不会被重分配。

  2. 染色指针技术:

    ZGC把一些标记信息存储在引用中,使得访问这些信息仅通过引用即可,但也使得最大内存限制在4TB。

  3. 多重映射技术

ZGC流程:

(1) 并发标记

(2) 并发预备重分配:针对全堆扫描标记,针对重分配集回收

(3) 并发重分配:染色指针会记录该对象是否在重分配集中;重分配集维护一个转发表,访问到在重分配集中的对象时,会去转发表寻找新地址,同时修正引用。

(4) 并发重映射

3.7 内存分配策略

针对不同年龄段(对象头中的age)的对象进行内存分配原则如下:

  • 优先分配到Eden

  • 大对象直接分配到老年代

    尽量避免程序中出现过多的大对象

  • 长期存活的对象分配到老年代

四、Java内存模型与线程

4.1 Java内存模型

可以说是Java线程内存模型,它是基于CPU缓存模型来建造,主内存可类比物理上的主内存,工作内存可类比高速缓存。线程只能操作工作内存。

在这里插入图片描述

4.1.1 主内存与工作内存

Java内存模型的主要目的是定义程序中各种变量的访问规则(此处变量指堆、方法区中的线程共享变量)。

4.1.2 内存间交互操作

8种原子操作:

(1) Lock: 作用于主内存变量,把一个变量标识为一个线程独占的状态

(2) Unlock:解锁一个资源

(3) Read:作用于主内存变量,把一个变量的值从主内存传输至工作内存,以便load

(4) Load:作用于工作内存变量,把read来的值存入工作内存的变量副本中

(5) Use:作用于工作内存变量,把工作内存中的一个变量值传给执行引擎

(6) Assign:当虚拟机执行变量赋值操作,把执行引擎传回的值赋给工作内存的变量

(7) Store:作用于工作内存变量,把一个变量的值从工作内存传输至主内存,以便write

(8) Write:作用于主内存变量,把store的值放入主内存变量

Assign store write必须成对出现;Lock会清空工作内存中该变量的值;Unlock前必须先同步

在这里插入图片描述

  • 总线(MESI缓存一致性协议):

    ​ 当在修改共享变量值后马上同步回主内存,其他cpu监听总线,然后监听到其他线程修改时,则会将工作内存的值失效,重新触发一次read,load,use操作进行修改执行引擎的值。

4.1.3 Volatile

​ Use前必须load;assign后必须store;先被use的先read,先被assign的先store。

  • Volatile缓存可见性实现原理:

    底层实现通过汇编lock前缀指令,就会锁定这块内存区域的缓存(缓存行锁定),并写回到主内存中

    lock指令解释:会将当前处理器缓存行的数据立即写回到系统内存中,这个操作会因此其他CPU缓存改内存地址数据失效(MESI协议,触发CPU总线嗅探机制)

  • lock位置是在store前,然后write到主内存后unlock,这样的好处:

    ​ (1)锁的力度小,多个线程同时写内存时,解决了并发的问题,只加写不加读。

    ​ (2)不影响系统性能。

  • 保证可见性、有序性、不保证原子性:

    原因:若同时操作一个共享变量,则lock前缀指令触发另一个线程总线嗅探,会使它操作失效,再重新进行下一次操作。

    例如:两线程同时执行对同一变量++操作,则可能会使执行++次数不变,但加的值减少(失效了),需在操作方法上加Synchronized关键字保证原子性。

4.2 线程

4.2.1 Java线程调度

  • 协同式线程调度:一个线程执行结束再开始下一个线程。

​ 优点:实现简单;线程切换可控,无并发问题。

​ 缺点:执行时间不可控,容易造成阻塞。

  • 抢占式线程调度(Java调度方式):系统决定执行时间。

​ 优点:执行时间可控。可以通过设置优先级,使线程更容易被唤醒。

4.2.2 状态转换

在这里插入图片描述

五、JVM常用参数

5.1 标准参数

Verbose

-verbose:class

​ 输出jvm载入类的相关信息,当jvm报告说找不到类或者类冲突时可此进行诊断。

-verbose:gc

​ 输出每次GC的相关情况。

-verbose:jni

​ 输出native方法调用的相关情况,一般用于诊断jni调用错误信息。

5.2 扩展参数(-X)

内存大小

-Xms

​ 指定初始堆大小(最小堆大小)

-Xmx

​ 指定最大堆大小

-Xmn

​ 指定新生代大小

-Xss

​ 指定每个线程的栈大小

5.3 非Stable参数(-XX)

-XX:PermSize

​ 持久代(方法区)的初始内存大小

-XX:MaxPermSize

​ 持久代(方法区)的最大内存大小

-XX:NewRadio

​ 新生代与老年代的比值

-XX:SurvivorRadio

​ Eden与Survivor的比值

六、锁

6.1 锁优化

6.1.1 自旋锁与自适应自旋

多核机器中,对于阻塞锁,自动引入自旋(先不进行阻塞,进行几次循环),如果自旋成功(即持锁线程已经释放锁),就拿到锁继续执行,避免了阻塞影响性能。

​ 自旋的次数受之前在该锁的自旋情况影响,自适应调整。

6.1.2 锁消除

​ JVM经过逃逸分析,发现锁对象不会逸出线程的引用时(即其他线程没有使用该锁),会消除掉锁。

6.1.3 锁粗化

​ 较短的间隔时间内,多次对同一资源加锁,JVM会将所有锁合并为一个“粗”锁。

6.1.4 轻量级锁

​ 对于绝大部分锁,在同步周期内(一段时间内)是没有竞争发生的。

获取一个对象的锁时,使用CAS替换操作判定是否被占用。若替换成功(未占用),使用轻量级锁(即不映射到操作系统)。若已被占用,且是轻量级锁,则会锁膨胀升级为重量级锁。

6.1.5 偏向锁

​ 锁会偏向于第一个获得它的线程。

​ 只有第一次使用CAS将线程ID设置到对象Mark Word,在对象Mark Word中记录下第一个线程的id,并进入偏向锁模式。如果此后仍旧是这个线程获取该锁,则都没有任何同步操作。

直到出现第二个线程试图获取该锁,则进行锁撤销(撤销掉偏向锁),变成无锁状态或轻量级锁状态。

​ 计算过hash值的对象无法进入偏向锁状态,偏向锁状态下的对象收到hash计算请求时,会进入重量级锁状态。