【知识点005】JVM

Java内存区域

Java运行时数据区

图片

  • 虚拟机栈 : Java 虚拟机栈是线程私有的数据区,Java 虚拟机栈的生命周期与线程相同,虚拟机栈也是局部变量的存储位置。方法在执行过程中,会在虚拟机栈中创建一个 栈帧(stack frame)。每个方法执行的过程就对应了一个入栈和出栈的过程。

图片

  • 本地方法栈: 本地方法栈也是线程私有的数据区,本地方法栈存储的区域主要是 Java 中使用 native 关键字修饰的方法所存储的区域。
  • 程序计数器:程序计数器也是线程私有的数据区,这部分区域用于存储线程的指令地址,用于判断线程的分支、循环、跳转、异常、线程切换和恢复等功能,这些都通过程序计数器来完成。
  • 方法区:方法区是各个线程共享的内存区域,它用于存储虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • :堆是线程共享的数据区,堆是 JVM 中最大的一块存储区域,所有的对象实例都会分配在堆上。JDK 1.7后,字符串常量池从永久代中剥离出来,存放在堆中。
  • 运行时常量池:运行时常量池又被称为 Runtime Constant Pool,这块区域是方法区的一部分,它的名字非常有意思,通常被称为 非堆。它并不要求常量一定只有在编译期才能产生,也就是并非编译期间将常量放在常量池中,运行期间也可以将新的常量放入常量池中,String 的 intern 方法就是一个典型的例子。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据

本机直接内存的分配不会受 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

对象的创建

图片

类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

声明类型引用

声明类型引用很简单,比如Sheep sheep = new Sheep()就会声明一个Sheep类型的引用sheep

分配内存

类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式“指针碰撞”“空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 本地线程分配缓存(Thread Local Allocation Buffer),为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。

初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

image-20220315141251265

构造方法初始化

最后是调用类的构造方法来进行进行构造方法内描述的初始化动作。

对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充

Hotspot 虚拟机的对象头包括两部分信息第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个数组,则对象头中还会有一块记录数组长度的数据。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄直接指针 两种方式。

指针: 指向对象,代表一个对象在内存中的起始地址。

句柄: 可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。

句柄访问

Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据对象类型数据各自的具体地址信息,具体构造如下图所示:

图片

优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中实例数据指针,而引用本身不需要修改。

直接指针

如果使用直接指针访问,引用 中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。

图片

优势:速度更,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种方式。

GC

垃圾收集器:除了垃圾回收这个本职工作外,它还负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统的协作等职责。

四种引用

  • 强引用:指代码中常见的引用赋值,如Object obj = new Object()。在任何情况下,只要强引用关系存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用:用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发送内存溢出异常之前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用:也是用来描述非必须对象,但是它的强度要比软引用弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用:也称“幽灵引用”和“幻影引用”,它是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会影响它的生命时间,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被回收时收到一个系统通知。

在JDK1.2之后,提供了SoftReference、WeakReference、PhantomReference来分别实现软引用、弱引用、虚引用。

分代收集理论,为什么要分新生代和老年代?

  1. 弱分代假说:绝大多数对象都是朝生夕灭的。
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

为什么要划分区域?如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中到一起,每次回收都只关注如何保留少量的存活而不是去标记哪些大量将要被回收的对象的话,就能以较低的代价回收到大量的空间,如果剩下的都是难以消亡的对象,那么把它们集中放一起,虚拟机便可以使用较低的频率来回收这个区域,这样就兼顾了垃圾收集的的时间开销和内存空间的有效利用。

Partial GC、Full GC

  • 部分收集(Partial GC):
    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集,目前只有CMS有这种行为。
    • 混合收集(Mixed GC):收集整个新生代和部分老年代的垃圾收集,目前只有G1有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

垃圾收集算法

标记-清除算法:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。

缺点:第一个是执行效率不稳定,如果堆中有大量需要回收的对象,此时必须进行大量标记和清除的动作。第二个是内存空间的碎片化问题。

标记-复制算法:为解决标记-清除算法的效率不稳定而提出的算法,由一开始的半区复制,演变为“Appel式回收”,即将新生代分为一个较大的Eden空间和两块较小的Survivor空间。每次分配内存只使用Eden和其中的一块Survivor区,发生垃圾收集时,将存活的对象复制到另外一块Survivor区。

标记-整理算法:根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

根节点枚举

即可达性分析算法的第一个步骤,找到所有的GC Roots。

固定可作为GC Roots的节点主要在全局性的引用(例如常量和类静态属性)与执行上下文(如栈帧中的本地变量表)中,尽管目标明确,但是Java应用越来越大,光是方法区的大小就常有数百上千兆,做到高效查找并非易事。但也并非一个不漏地从方法区等GC Roots开始查找。在HotSpot的解决方案里,使用一组称为OopMap的数据结构来直接得到哪些地方存放着对象引用。到现在为止,所有收集器在根节点枚举时都必须暂停用户线程,即Stop The World,即必须在一个能保证一致性的快照中才能进行,不会出现在分析过程中,根节点集合的对象引用关系还在不断变化的情况。

固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,如参数,局部变量,临时变量等;
  • 在方法区中类静态属性引用的对象,如Java类的引用类型静态变量;
  • 在方法区中常量引用的对象,如字符串常量池中的引用;
  • 在本地方法栈中JNI(Native方法)引用的对象;
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(NullPointException)等,还有系统类加载器;
  • 所有被同步锁(synchronized关键字)持有的对象;
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

安全点、安全区

安全点:有了OopMap,Hotspot可以快速地完成GC Roots枚举,但是可能导致引用关系变化,即导致OopMap内容变化的指令有很多,为了避免为每一条指令都生成对应的OopMap,虚拟机只在“特定的位置”记录这些信息,这些位置就是安全点

安全点的选取是以“是否具有让程序长时间执行的特征”为标准选定的,即方法调用,循环跳转,异常跳转等具有指令序列的复用特征的指令。

如何在垃圾收集发生时让线程都跑到最近的安全点,然后停顿下来?

  • 抢断式中断
  • 主动式中断:当需要中断线程时,简单设置一个标志位,各个线程执行过程中会不停地去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是程序不运行,即没分配处理时间,如线程处于Sleep或Blocked时呢?此时线程无法响应虚拟机的中断请求,不能走到安全点去中断挂起自己。此时,需要引入安全区域

安全区域:指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域的任意地方开始垃圾收集都是安全的,我们也可以把安全区域看作时被扩展拉伸的安全点。

用户线程执行到安全区域中的代码时,首先会标识自己已经进入安全区域,这段时间内垃圾收集不需要管声明自己在安全区域的线程,当线程要离开安全区域时,会检查根节点枚举是否完成,如果是,则线程就当没事发生,否则,就会一直等待,直到收到可以离开安全区域的信号为止。

记忆集

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,是为了解决跨代引用问题,避免把整个老年代都加进GC Roots的扫描范围(即缩减GC Roots扫描范围的问题,事实上不止新生代和老年代存在跨代引用,所有部分区域收集都会有这些问题)。

记录精度:

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

卡精度用的是卡表的方式去实现记忆集。即卡表是记忆集的一种具体实现。

把堆中分为很多块,每块 512 字节(卡页),用字节数组中的一个元素来表示某一块,1表示脏块,里面存在跨代引用。

图片

在垃圾收集发生时,只要筛选出卡表变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入到GC Roots中一并扫描。

写屏障

卡表元素如何维护?即它们何时变脏,谁来把它们变脏?

何时?当有其它分代区域中对象引用了本区域对象时,对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。

谁?在Hotspot中是用写(后)屏障来维护卡表状态。

void oop_field_store(oop* field, oop new_value){
    //引用字段赋值操作
    *field = new_value;
    //写后屏障,在这里完成卡表状态更新
    post_write_barrier(field, new_value);
}

启用写屏障后,每次只要对引用进行更新,就会产生额外开销,只不过这个开销与Minor GC时扫描整个老年代的代价相比要低得多。

除了写屏障的开销外,卡表在高并发场景下还有“伪共享”问题。

伪共享:现代CPU的缓存系统中是以缓存行单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享一个缓存行,就会彼此影响而导致性能降低。

为避免伪共享,一种简单的解决方案是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即:

if( CARD_TABLE [this address >> 9] != 0 )
	CARD_TABLE [this address >> 9] = 0

并发的可达性分析

可达性分析算法理论上要求全过程都基于一个能保证一致性的快照张才能够进行分析。虽然在根节点枚举这个步骤中,它带来的停顿已经是非常短暂且固定,但是,从GC Roots继续往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比关系了。由于“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果能够削减这部分停顿时间的话,那么收益也是系统性的,由此想要实现在GC Roots向下遍历对象图的过程中,不需要Stop The World,即与用户线程并发执行。

但是,根据三色标记,我们知道如果用户线程与收集器并发执行,收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,可能会把原本存活的对象误标记为已消亡。

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

  • 赋值器插入了一条或多条从黑色对象到白色对象的引用。
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此,破坏其中一个条件就能解决并发扫描时的对象消失问题。

增量更新:破坏的是第一个条件。当黑色对象插入新的指向白色对象的引用关系时,就将这个引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一遍,这可以简化理解为,黑色对象一旦插入了指向白色对象的引用,它就变回灰色对象了。

原始快照(SATP):破坏的是第二个条件。当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用关系记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一遍,这可以简化理解为,无论引用关系删除与否,都会按照刚开始扫描那一刻的对象图快照来进行搜索。

无论是堆引用关系的插入还是删除,虚拟机的记录操作都是通过写屏障来实现的。

CMS是基于增量更新来做并发标记的,而G1、Shenandoah是用原始快照来实现。

TLAB

这个得从内存申请说起。

一般而言生成对象需要向堆中的新生代申请内存空间,而堆又是全局共享的,像新生代内存又是规整的,是通过一个指针来划分的。

图片

内存是紧凑的,新对象创建指针就右移对象大小 size 即可,这叫指针碰撞(Bump the pointer)。

内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 本地线程分配缓存(Thread Local Allocation Buffer),为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。

CMS

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: Stop The World,并记录下直接与 GC Roots直接关联的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录(增量更新),这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

它有下面三个明显的缺点:

  • 对 CPU 资源敏感:事实上面向并发设计的程序都对处理器资源比较敏感。
  • 无法处理浮动垃圾,如果CMS运行期间预留的内存无法满足程序分配新对象的需要,可能出现“并发失败”(Concurrent Mode Failure),这时只能冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,即导致另一次完全“Stop The World”的Full GC产生。浮动垃圾是指并发标记和并发清理阶段,用户线程还在继续运行,即有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束后的,只好留到下一次垃圾收集再清理掉,这一部分就为浮动垃圾。
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

G1

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

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备一下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间,这里的操作涉及存活对象的移动,是必须暂停用户线程(Shenandoah中使用转发指针,ZGC中使用染色指针实现并发整理),由多条收集器线程并行完成的。

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

与CMS相比,G1的缺点:如在用户进程运行过程中,G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都比CMS要高。

就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表要更复杂,而且堆中的每个Region都必须有一份卡表,这导致G1的记忆集可能会占整个堆容量的20%乃至更多的内存空间;而CMS的卡表只有一份,只需要处理老年代到新生代的引用。

在执行负载上,如它们都使用到写屏障,CMS使用写后屏障来维护卡表,而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更繁琐的)卡表维护操作外,为了实现原始快照(SATB),还需要使用写前屏障来跟踪并发时的指针变化情况。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似消息队列的结构,把写前屏障和写后屏障要做的事情都放到队列中,然后再异步处理。

cms 的记忆集和 G1 的记忆集有什么不一样?

cms 的记忆集的实现是卡表即 card table。

通常实现的记忆集是 points-out 的,我们知道记忆集是用来记录非收集区域指向收集区域的跨代引用,它的主语其实是非收集区域,所以是 points-out 的。

在 cms 中只有老年代指向年轻代的卡表,用于年轻代 gc。

而 G1 是基于 region 的,所以在 points-out 的卡表之上还加了个 points-into 的结构。

因为一个 region 需要知道有哪些别的 region 有指向自己的指针,然后还需要知道这些指针在哪些 card 中。

其实 G1 的记忆集就是个 hash table,key 就是别的 region 的起始地址,然后 value 是一个集合,里面存储这 card table 的 index。

我们来看下这个图就很清晰了。

图片

像每次引用字段的赋值都需要维护记忆集开销很大,所以 G1 的实现利用了 logging write barrier。

也是异步思想,会先将修改记录到队列中,当队列超过一定阈值由后台线程取出遍历来更新记忆集。

cms 和 G1 为了维持并发的正确性分别用了什么手段?

cms 和 g1 分别通过增量更新和 SATB 来打破这两个充分必要条件,维持了 GC 线程与应用线程并发的正确性。

cms 用了增量更新(Incremental update),打破了第一个条件,通过写屏障将插入的白色对象标记成灰色,即加入到标记栈中,在 remark 阶段再扫描,防止漏标情况。

G1 用了 SATB(snapshot-at-the-beginning),打破了第二个条件,会通过写屏障把旧的引用关系记下来,之后再把旧引用关系再扫描过。这个从英文名词来看就已经很清晰了。讲白了就是在 GC 开始时候如果对象是存活的就认为其存活,等于拍了个快照。

而且 gc 过程中新分配的对象也都认为是活的。每个 region 会维持 TAMS (top at mark start)指针,分别是 prevTAMS 和 nextTAMS 分别标记两次并发标记开始时候 Top 指针的位置。

Top 指针就是 region 中最新分配对象的位置,所以 nextTAMS 和 Top 之间区域的对象都是新分配的对象都认为其是存活的即可。

图片

而利用增量更新的 cms 在 remark 阶段需要重新扫描所有线程栈和整个年轻代,因为等于之前的根有新增,所以需要重新扫描过,如果年轻代的对象很多的话会比较耗时。

要注意这阶段是 STW 的,很关键,所以 CMS 也提供了一个 CMSScavengeBeforeRemark 参数,来强制 remark 阶段之前来一次 YGC。

而 g1 通过 SATB 的话在最终标记阶段只需要扫描 SATB 记录的旧引用即可,从这方面来说会比 cms 快,但是也因为这样浮动垃圾会比 cms 多。

现在jdk默认使用的是哪种垃圾回收器?

jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)

jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)

jdk1.9 默认垃圾收集器G1

内存分配策略

  • 对象优先在 Eden 区分配:当 Eden 区分配没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC。如果本次 GC 后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。
  • 大对象直接进入老年代:所谓大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,会导致在内存还有不少空间的情况下提前触发 GC 以获取足够的连续空间来安置新对象。
  • 长期存活对象将进入老年代
  • 动态对象年龄判断:如果在Survivor中低于或等于某年龄的所有对象的大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。
  • 空间分配担保:在发生Minor GC之前,虚拟机先检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果是,则进行Minor GC,否则,检查-XX:HandlePromotionFailure参数的设置值是否允许担保失败,如果是,则检查老年代最大可以连续空间是否大于历代晋升到老年代对象的平均大小,如果是,将尝试Minor GC,如果否,或者-XX:HandlePromotionFailure参数不允许冒险,则进行Full GC。但在JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或历代晋升平均大小,就会进行Minor GC,否则Full GC。

虚拟机类加载机制

类的生命周期

一个类的完整生命周期如下:

img

类加载过程

Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?

系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析

img

加载

类加载过程的第一步,主要完成下面 3 件事情:

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

虚拟机规范上面这 3 点并不具体,因此是非常灵活的。比如:"通过全类名获取定义此类的二进制字节流" 并没有指明具体从哪里获取、怎样获取。比如:比较常见的就是从 ZIP 包中读取(日后出现的 JAREARWAR 格式的基础)、其他文件生成(典型应用就是 JSP)等等。

一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass() 方法)。数组类型不通过类加载器创建,它由 Java 虚拟机直接创建。

加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。

验证

验证阶段示意图

元数据验证主要对类的元数据信息进行语义校验,而字节码验证则是对类的方法体(Class文件中的Code属性)进行校验分析。

在Class文件中,Code属性用于描述代码,所有其他的数据项目都用于描述元数据(包括类,字段,方法定义及其他信息)。

符号引用验证发生在虚拟机将符号引用转化为直接引用的时候,即解析阶段发生。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  1. 这时候进行内存分配的仅包括类变量( Class Variables ,即静态变量,被 static 关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
  2. 从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。相关阅读:《深入理解Java虚拟机(第3版)》勘误#75 (opens new window)
  3. 这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,编译时javac会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为111(也就是说常量在准备阶段就被赋值,而静态变量要等到初始化阶段)。

基本数据类型的零值 : (图片来自《深入理解 Java 虚拟机》第 3 版 7.33 )

基本数据类型的零值

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。

符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。在程序实际运行时,只有符号引用是不够的,举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。

综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

《Java虚拟机规范》并未规定解析阶段发生的具体时间,所有虚拟机实现可以根据需要自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。但对于invokedynamic指令来说,必须等到程序实际运行到这条指令时,解析动作才进行。

初始化

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

说明: <clinit> ()方法是编译器在编译阶段自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并生成的。

对于<clinit> () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit> () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个进程阻塞,并且这种阻塞很难被发现。

对于初始化阶段,虚拟机严格规范了有且只有 5 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):

  1. 当遇到 new、getstatic、putstatic、invokestatic这 4 条直接码指令时,比如 new一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname("..."), newInstance() 等等。如果类没初始化,需要触发其初始化。
  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
  5. MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。
  6. 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

卸载

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

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

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

所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。

只要想通一点就好了,jdk 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 jdk 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

类加载器

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 包和类。

双亲委派模型

双亲委派模型介绍

每一个类都有一个对应它的类加载器。系统中的 ClassLoader 在协同工作的时候会默认使用 双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派给父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

ClassLoader

每个类加载都有一个父类加载器,我们通过下面的程序来验证。

public class ClassLoaderDemo {
    public static void main(String[] args) {
        System.out.println("ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader());
        System.out.println("The Parent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent());
        System.out.println("The GrandParent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent().getParent());
    }
}

Output

ClassLodarDemo's ClassLoader is sun.misc.Launcher$AppClassLoader@18b4aac2
The Parent of ClassLodarDemo's ClassLoader is sun.misc.Launcher$ExtClassLoader@1b6d3586
The GrandParent of ClassLodarDemo's ClassLoader is null

AppClassLoader的父类加载器为ExtClassLoaderExtClassLoader的父类加载器为 null,null 并不代表ExtClassLoader没有父类加载器,而是 BootstrapClassLoader

双亲委派模型实现源码分析

双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoaderloadClass() 中,相关代码如下所示。

private final ClassLoader parent;
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先,检查请求的类是否已经被加载过
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {//父加载器不为空,调用父加载器loadClass()方法处理
                        c = parent.loadClass(name, false);
                    } else {//父加载器为空,使用启动类加载器 BootstrapClassLoader 加载
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                   //抛出异常说明父类加载器无法完成加载请求
                }

                if (c == null) {
                    long t1 = System.nanoTime();
                    //自己尝试加载
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

双亲委派模型的好处

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类,即类本身和加载它的类加载器共同确定类的唯一性),也保证了 Java 的核心 API 不被篡改。可以尝试写一个与rt.jar类库中已有类重名的java类,将会发现它可以正常编译,但永远无法加载运行,因为有双亲委派机制,会由启动类加载器去加载,但会发现请求加载的类已经加载过,所以会忽略。

如果不想用双亲委派模型?

为了避免双亲委托机制,我们可以自己定义一个类加载器,然后重写 loadClass() 即可。

自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法

Java内存模型与线程

为什么要有内存模型?

要想回答这个问题,我们需要先弄懂传统计算机硬件内存架构。

图片

(1)CPU

去过机房的同学都知道,一般在大型服务器上会配置多个CPU,每个CPU还会有多个,这就意味着多个CPU或者多个核可以同时(并发)工作。如果使用Java 起了一个多线程的任务,很有可能每个 CPU 都会跑一个线程,那么你的任务在某一刻就是真正并发执行了。

(2)CPU Register

CPU Register也就是 CPU 寄存器。CPU 寄存器是 CPU 内部集成的,在寄存器上执行操作的效率要比在主存上高出几个数量级。

(3)CPU Cache Memory

CPU Cache Memory也就是 CPU 高速缓存,相对于寄存器来说,通常也可以成为 L2 二级缓存。相对于硬盘读取速度来说内存读取的效率非常高,但是与 CPU 还是相差数量级,所以在 CPU 和主存间引入了多级缓存,目的是为了做一下缓冲。

(4)Main Memory

Main Memory 就是主存,主存比 L1、L2 缓存要大很多。

注意:部分高端机器还有 L3 三级缓存。

缓存一致性问题

由于主存与 CPU 处理器的运算能力之间有数量级的差距,所以在传统计算机内存架构中会引入高速缓存来作为主存和处理器之间的缓冲,CPU 将常用的数据放在高速缓存中,运算结束后 CPU 再将运算结果同步到主存中。

使用高速缓存解决了 CPU 和主存速率不匹配的问题,但同时又引入另外一个新问题:缓存一致性问题。

图片

在多CPU的系统中(或者单CPU多核的系统),每个CPU内核都有自己的高速缓存,它们共享同一主内存(Main Memory)。当多个CPU的运算任务都涉及同一块主内存区域时,CPU 会将数据读取到缓存中进行运算,这可能会导致各自的缓存数据不一致。

因此需要每个 CPU 访问缓存时遵循一定的协议,在读写数据时根据协议进行操作,共同来维护缓存的一致性。这类协议有 MSI、MESI、MOSI、和 Dragon Protocol 等。

处理器优化和指令重排序

为了提升性能在 CPU 和主内存之间增加了高速缓存,但在多线程并发场景可能会遇到缓存一致性问题。那还有没有办法进一步提升 CPU 的执行效率呢?答案是:处理器优化。

为了使处理器内部的运算单元能够最大化被充分利用,处理器会对输入代码进行乱序执行处理,这就是处理器优化。

除了处理器会对代码进行优化处理,很多现代编程语言的编译器也会做类似的优化,比如像 Java 的即时编译器(JIT)会做指令重排序。

图片

处理器优化其实也是重排序的一种类型,这里总结一下,重排序可以分为三种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

并发编程的问题

上面讲了一堆硬件相关的东西,有些同学可能会有点懵,绕了这么大圈,这些东西跟 Java 内存模型有啥关系吗?不要急咱们慢慢往下看。

熟悉 Java 并发的同学肯定对这三个问题很熟悉:『可见性问题』、『原子性问题』、『有序性问题』。如果从更深层次看这三个问题,其实就是上面讲的『缓存一致性』、『处理器优化』、『指令重排序』造成的。

图片

缓存一致性问题其实就是可见性问题,处理器优化可能会造成原子性问题,指令重排序会造成有序性问题,看看这样是不是都联系上了。

出了问题总是要解决的,那有什么办法呢?首先想到简单粗暴的办法,干掉缓存让 CPU 直接与主内存交互就解决了可见性问题,禁止处理器优化和指令重排序就解决了原子性和有序性问题,但这样一夜回到解放前了,显然不可取。

所以技术前辈们想到了在物理机器上定义出一套内存模型, 规范内存的读写操作。内存模型解决并发问题主要采用两种方式:限制处理器优化使用内存屏障

Java 内存模型

同一套内存模型规范,不同语言在实现上可能会有些差别。接下来着重讲一下 Java 内存模型实现原理。

Java 运行时内存区域与硬件内存的关系

了解过 JVM 的同学都知道,JVM 运行时内存区域是分片的,分为栈、堆等,其实这些都是 JVM 定义的逻辑概念。在传统的硬件内存架构中是没有栈和堆这种概念。

图片

从图中可以看出栈和堆既存在于高速缓存中又存在于主内存中,所以两者并没有很直接的关系。

Java 线程与主内存的关系

Java 内存模型是一种规范,定义了很多东西:

  • 所有的变量都存储在主内存(Main Memory)中。
  • 每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的拷贝副本。
  • 线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存。
  • 不同的线程之间无法直接访问对方本地内存中的变量。

看文字太枯燥了,来张图:

图片

线程间通信

如果两个线程都对一个共享变量进行操作,共享变量初始值为 1,每个线程都变量进行加 1,预期共享变量的值为 3。在 JMM 规范下会有一系列的操作。

图片

为了更好的控制主内存和本地内存的交互,Java 内存模型定义了八种操作来实现:

  • lock:锁定。作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock:解锁。作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read:读取。作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load:载入。作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use:使用。作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign:赋值。作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store:存储。作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write:写入。作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

注意:工作内存也就是本地内存的意思。

volatile

volatile是Java虚拟机提供的最轻量级的同步机制。

当一个变量被定义为volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的,而普通变量不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。使用volatile变量的第二个语义是禁止指令重排序优化。

Java内存模型通过对volatile变量定义特殊的规则,来实现volatile变量的两个语义。

假定V为volatile型变量,

实现的效果为:

  • 在工作内存中,每次使用V前必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V做出的修改;
  • 在工作内存中,每次修改V后都必须立刻同步回主内存,用于保证其他线程可以看到自己对变量V做出的修改;
  • volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。

线程

Java线程的实现

Java线程如何实现并不受Java虚拟机规范的约束,取决于具体虚拟机的实现。以Hotspot为例,Java的每一个线程都是直接映射到一个操作系统原生线程来实现的,Hotspot是不会去干涉线程调度的, 全权交给底下的操作系统去处理。

Java线程状态

关于线程生命周期的不同状态,在Java 5以后,线程状态被明确定义在其公共内部枚举类型java.lang.Thread.State中,分别是:

新建(NEW),表示线程被创建出来还没真正启动的状态,可以认为它是个Java内部状态。

就绪(RUNNABLE),表示该线程已经在JVM中执行,当然由于执行需要计算资源,它可能是正在运行,也可能还在等待系统分配给它CPU片段,在就绪队列里面排队。在其他一些分析中,会额外区分一种状态RUNNING,但是从Java API的角度,并不能表示出来。

阻塞(BLOCKED),这个状态和我们前面两讲介绍的同步非常相关,阻塞表示线程在等待Monitor lock。比如,线程试图通过synchronized去获取某个锁,但是其他线程已经独占了,那么当前线程就会处于阻塞状态。

等待(WAITING),表示正在等待其他线程采取某些操作。一个常见的场景是类似生产者消费者模式,发现任务条件尚未满足,就让当前消费者线程等待(wait),另外的生产者线程去准备任务数据,然后通过类似notify等动作,通知消费线程可以继续工作了。Thread.join()也会令线程进入等待状态。

计时等待(TIMED_WAIT),其进入条件和等待状态类似,但是调用的是存在超时条件的方法,比如wait或join等方法的指定超时版本,如下面示例:

public final native void wait(long timeout) throws InterruptedException;

终止(TERMINATED),不管是意外退出还是正常执行结束,线程已经完成使命,终止运行,也有人把这个状态叫作死亡。

线程安全与锁优化

线程安全的定义

《Java并发编程实战》中的定义为:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

线程安全的实现方法

互斥同步

同步是指多个线程并发访问共享数据的时候,保证共享数据在同一个时刻只被一条(或一些,使用信号量的时候)线程使用,而互斥是实现同步的一种手段,临界区、互斥量、信号量都是常见的互斥实现方式。

synchronized是Java中最基本的互斥同步手段,synchronized关键字经javac编译后,会在同步块的前后形成monitorenter和monitorexit这两个字节码指令。这两个指令都需要一个reference类型的参数来指明要锁定和解锁的对象。

JDK5起提供了java.util.concurrent包,即JUC,其中的java.util.concurrent.locks.Lock接口成了Java实现互斥同步的另一种手段。基于Lock接口,用户能够以非块结构来实现互斥同步,从而摆脱了语言特性的束缚,改为在类库层面去实现同步,这也为日后扩展出不同调度算法,不同特征,不同性能,不同语义的各种锁提供了广阔的空间。

重入锁(ReentrantLock)是Lock接口最常见的一种实现,它与synchronized一样是可重入的,但增加了三项高级功能,等待可中断,可实现公平锁和锁可以绑定多个条件。

非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒带来的性能开销,因此互斥同步也被称为阻塞同步。从解决问题方式上看,互斥同步是一种悲观的并发策略。而非阻塞同步则是基于冲突检测的乐观并发策略,在Java中有CAS操作。

锁优化

自旋锁与自适应自旋锁

JDK6中对自旋锁的优化,引入了自适应自旋锁,即自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。

锁消除

虚拟机即时编译器在运行时,对一些代码要求同步,但是对检测到不可能存在共享数据竞争的锁进行消除。

锁粗化

原则上,编写代码时都希望将同步块的作用范围控制得较小。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机检测到这样的情况,会将加锁同步的范围扩展(粗化)到整个操作序列的外部。

轻量级锁

轻量级锁是相对于操作系统互斥量来实现的“重量级”锁而言的,但是轻量级锁并不用来替代重量级锁的,它是指在没有多线程竞争的前提下,减少重量级锁使用操作系统互斥量产生的性能消耗。

要理解轻量级锁,必须要对虚拟机对象的内存布局(尤其是对象头部分)。

HotSpot虚拟机的对象头分为两部分:

  • 第一部分用户存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等。这部分数据的长度在32位和64位的虚拟机中分别会占用32个或64个比特,官方称它为“Mark Word”,它是实现轻量级锁和偏向锁的关键。
  • 第二部分是用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度

由于对象头信息是与对象自身定义的数据无关的额外存储成本,Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。
Mark Word会根据对象的状态复用自己的存储空间。下面是对象的状态对应的对象头的存储内容表

HotSpot虚拟机对象头Mark Word

轻量级锁加锁

  1. 在代码即将进入同步块的时候,如果此同步对象没有被锁定(标志位“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。
  2. 然后,虚拟机将使用CAS操作尝试把对象的Mark Word 更新为指向Lock Record 的指针。
  3. 如果这个更新操作成功了,即代表线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。
  4. 如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁,虚拟机会检查对象的Mark Word是否指向当前线程的栈帧,如果是,则继续,否则,说明锁对象已经被其他线程抢占了。如果出现两条以上的线程争用一个锁的情况,那么轻量级锁会膨胀为重量级锁。

上面说了轻量级锁的加锁过程了,它的解锁过程也同样是通过CAS操作来进行的。

  1. 如果对象的Mark Word 仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Wrod和线程中复制的Displaced Mark Word替换回来。
  2. 如果能够替换,那整个同步过程就顺利完成了;
  3. 如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁总结:
轻量级锁能提升性能的依据是:“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”。
如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。
如果说轻量级锁是在无竞争的情况下使用CAS操作消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

偏向锁的定义:这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他线程获取,则持有偏向锁的线程将不需要再进行同步。

偏向锁加锁过程

  1. 当虚拟机启动了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。
  2. 同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word之中。
  3. 如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块是,虚拟机都可以不再进行任何同步操作。

当出现另外一个线程区尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。

常用JVM参数

堆内存相关

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

显式指定堆内存–Xms-Xmx

与性能有关的最常见实践之一是根据应用程序要求初始化堆内存。如果我们需要指定最小和最大堆大小(推荐显示指定大小),以下参数可以帮助你实现:

-Xms<heap size>[unit] 
-Xmx<heap size>[unit]
  • heap size 表示要初始化内存的具体大小。
  • unit 表示要初始化内存的单位。单位为“ g” (GB) 、“ m”(MB)、“ k”(KB)。

举个栗子🌰,如果我们要为JVM分配最小2 GB和最大5 GB的堆内存大小,我们的参数应该这样来写:

-Xms2G -Xmx5G

显式新生代内存(Young Generation)

根据Oracle官方文档 (opens new window),在堆总可用内存配置完成之后,第二大影响因素是为 Young Generation 在堆内存所占的比例。默认情况下,YG 的最小大小为 1310 MB,最大大小为无限制

一共有两种指定 新生代内存(Young Ceneration)大小的方法:

1.通过-XX:NewSize-XX:MaxNewSize指定

-XX:NewSize=<young size>[unit] 
-XX:MaxNewSize=<young size>[unit]

举个栗子🌰,如果我们要为 新生代分配 最小256m 的内存,最大 1024m的内存我们的参数应该这样来写:

-XX:NewSize=256m
-XX:MaxNewSize=1024m

2.通过-Xmn<young size>[unit]指定

举个栗子🌰,如果我们要为 新生代分配256m的内存(NewSize与MaxNewSize设为一致),我们的参数应该这样来写:

-Xmn256m 

GC 调优策略中很重要的一条经验总结是这样说的:

将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。

另外,你还可以通过-XX:NewRatio=<int>来设置新生代和老年代内存的比值。

比如下面的参数就是设置新生代(包括Eden和两个Survivor区)与老年代的比值为1。也就是说:新生代与老年代所占比值为1:1,新生代占整个堆栈的 1/2。

-XX:NewRatio=1

显式指定永久代/元空间的大小

从Java 8开始,如果我们没有指定 Metaspace 的大小,随着更多类的创建,虚拟机会耗尽所有可用的系统内存(永久代并不会出现这种情况)。

JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小

-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。

JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。

下面是一些常用参数:

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

垃圾收集相关

垃圾回收器

为了提高应用程序的稳定性,选择正确的垃圾收集 (opens new window)算法至关重要。

JVM具有四种类型的GC实现:

  • 串行垃圾收集器
  • 并行垃圾收集器
  • CMS垃圾收集器
  • G1垃圾收集器

可以使用以下参数声明这些实现:

-XX:+UseSerialGC
-XX:+UseParallelGC
-XX:+UseParNewGC
-XX:+UseG1GC

GC记录

为了严格监控应用程序的运行状况,我们应该始终检查JVM的垃圾回收性能。最简单的方法是以人类可读的格式记录GC活动。

使用以下参数,我们可以记录GC活动:

-XX:+UseGCLogFileRotation 
-XX:NumberOfGCLogFiles=< number of log files > 
-XX:GCLogFileSize=< file size >[ unit ]
-Xloggc:/path/to/gc.log

JVM监控和故障处理工具

JDK 命令行工具

这些命令在 JDK 安装目录下的 bin 目录下:

  • jps (JVM Process Status): 类似 UNIX 的 ps 命令。用于查看所有 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息;
  • jstat(JVM Statistics Monitoring Tool): 用于收集 HotSpot 虚拟机各方面的运行数据;
  • jinfo (Configuration Info for Java) : Configuration Info for Java,显示虚拟机配置信息;
  • jmap (Memory Map for Java) : 生成堆转储快照;
  • jhat (JVM Heap Dump Browser) : 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果;
  • jstack (Stack Trace for Java) : 生成虚拟机当前时刻的线程快照,线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。

jps:查看所有 Java 进程

jps(JVM Process Status) 命令类似 UNIX 的 ps 命令。

jps:显示虚拟机执行主类名称以及这些进程的本地虚拟机唯一 ID(Local Virtual Machine Identifier,LVMID)。jps -q :只输出进程的本地虚拟机唯一 ID。

C:\Users\SnailClimb>jps
7360 NettyClient2
17396
7972 Launcher
16504 Jps
17340 NettyServer

jstat: 监视虚拟机各种运行状态信息

jstat(JVM Statistics Monitoring Tool) 使用于监视虚拟机各种运行状态信息的命令行工具。 它可以显示本地或者远程(需要远程主机提供 RMI 支持)虚拟机进程中的类信息、内存、垃圾收集、JIT 编译等运行数据,在没有 GUI,只提供了纯文本控制台环境的服务器上,它将是运行期间定位虚拟机性能问题的首选工具。

jstat 命令使用格式:

jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]

比如 jstat -gc -h3 31736 1000 10表示分析进程 id 为 31736 的 gc 情况,每隔 1000ms 打印一次记录,打印 10 次停止,每 3 行后打印指标头部。

常见的 option 如下:

  • jstat -class vmid :显示 ClassLoader 的相关信息;
  • jstat -compiler vmid :显示 JIT 编译的相关信息;
  • jstat -gc vmid :显示与 GC 相关的堆信息;
  • jstat -gccapacity vmid :显示各个代的容量及使用情况;
  • jstat -gcnew vmid :显示新生代信息;
  • jstat -gcnewcapcacity vmid :显示新生代大小与使用情况;
  • jstat -gcold vmid :显示老年代和永久代的行为统计,从jdk1.8开始,该选项仅表示老年代,因为永久代被移除了;
  • jstat -gcoldcapacity vmid :显示老年代的大小;
  • jstat -gcpermcapacity vmid :显示永久代大小,从jdk1.8开始,该选项不存在了,因为永久代被移除了;
  • jstat -gcutil vmid :显示垃圾收集信息;

另外,加上 -t参数可以在输出信息上加一个 Timestamp 列,显示程序的运行时间。

jinfo: 实时地查看和调整虚拟机各项参数

jinfo vmid :输出当前 jvm 进程的全部参数和系统属性 (第一部分是系统的属性,第二部分是 JVM 的参数)。

jinfo -flag name vmid :输出对应名称的参数的具体值。比如输出 MaxHeapSize、查看当前 jvm 进程是否开启打印 GC 日志 ( -XX:PrintGCDetails :详细 GC 日志模式,这两个都是默认关闭的)。

C:\Users\SnailClimb>jinfo  -flag MaxHeapSize 17340
-XX:MaxHeapSize=2124414976
C:\Users\SnailClimb>jinfo  -flag PrintGC 17340
-XX:-PrintGC

使用 jinfo 可以在不重启虚拟机的情况下,可以动态的修改 jvm 的参数。尤其在线上的环境特别有用,请看下面的例子:

jinfo -flag [+|-]name vmid 开启或者关闭对应名称的参数。

C:\Users\SnailClimb>jinfo  -flag  PrintGC 17340
-XX:-PrintGC

C:\Users\SnailClimb>jinfo  -flag  +PrintGC 17340

C:\Users\SnailClimb>jinfo  -flag  PrintGC 17340
-XX:+PrintGC

jmap:生成堆转储快照

jmap(Memory Map for Java)命令用于生成堆转储快照。 如果不使用 jmap 命令,要想获取 Java 堆转储,可以使用 “-XX:+HeapDumpOnOutOfMemoryError” 参数,可以让虚拟机在 OOM 异常出现之后自动生成 dump 文件,Linux 命令下可以通过 kill -3 发送进程退出信号也能拿到 dump 文件。

jmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalizer 执行队列、Java 堆和永久代的详细信息,如空间使用率、当前使用的是哪种收集器等。和jinfo一样,jmap有不少功能在 Windows 平台下也是受限制的。

示例:将指定应用程序的堆快照输出到桌面。后面,可以通过 jhat、Visual VM 等工具分析该堆文件。

C:\Users\SnailClimb>jmap -dump:format=b,file=C:\Users\SnailClimb\Desktop\heap.hprof 17340
Dumping heap to C:\Users\SnailClimb\Desktop\heap.hprof ...
Heap dump file created

jhat: 分析 heapdump 文件

jhat 用于分析 heapdump 文件,它会建立一个 HTTP/HTML 服务器,让用户可以在浏览器上查看分析结果。

C:\Users\SnailClimb>jhat C:\Users\SnailClimb\Desktop\heap.hprof
Reading from C:\Users\SnailClimb\Desktop\heap.hprof...
Dump file created Sat May 04 12:30:31 CST 2019
Snapshot read, resolving...
Resolving 131419 objects...
Chasing references, expect 26 dots..........................
Eliminating duplicate references..........................
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.

访问 http://localhost:7000/

jstack:生成虚拟机当前时刻的线程快照

jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合.

生成线程快照的目的主要是定位线程长时间出现停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的原因。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者在等待些什么资源。

下面是一个线程死锁的代码。我们下面会通过 jstack 命令进行死锁检查,输出死锁信息,找到发生死锁的线程。

public class DeadLockDemo {
    private static Object resource1 = new Object();//资源 1
    private static Object resource2 = new Object();//资源 2

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "线程 2").start();
    }
}

Output

Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1

线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。

通过 jstack 命令分析:

C:\Users\SnailClimb>jps
13792 KotlinCompileDaemon
7360 NettyClient2
17396
7972 Launcher
8932 Launcher
9256 DeadLockDemo
10764 Jps
17340 NettyServer

C:\Users\SnailClimb>jstack 9256

可以看到 jstack 命令已经帮我们找到发生死锁的线程的具体信息。

JDK 可视化分析工具

JConsole:Java 监视与管理控制台

JConsole 是基于 JMX 的可视化监视、管理工具。可以很方便的监视本地及远程服务器的 java 进程的内存使用情况。你可以在控制台输出console命令启动或者在 JDK 目录下的 bin 目录找到jconsole.exe然后双击启动。

Visual VM:多合一故障处理工具

下面这段话摘自《深入理解 Java 虚拟机》。

VisualVM(All-in-One Java Troubleshooting Tool)是到目前为止随 JDK 发布的功能最强大的运行监视和故障处理程序,官方在 VisualVM 的软件说明中写上了“All-in-One”的描述字样,预示着他除了运行监视、故障处理外,还提供了很多其他方面的功能,如性能分析(Profiling)。VisualVM 的性能分析功能甚至比起 JProfiler、YourKit 等专业且收费的 Profiling 工具都不会逊色多少,而且 VisualVM 还有一个很大的优点:不需要被监视的程序基于特殊 Agent 运行,因此他对应用程序的实际性能的影响很小,使得他可以直接应用在生产环境中。这个优点是 JProfiler、YourKit 等工具无法与之媲美的。

VisualVM 基于 NetBeans 平台开发,因此他一开始就具备了插件扩展功能的特性,通过插件扩展支持,VisualVM 可以做到:

  • 显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)。
  • 监视应用程序的 CPU、GC、堆、方法区以及线程的信息(jstat、jstack)。
  • dump 以及分析堆转储快照(jmap、jhat)。
  • 方法级的程序运行性能分析,找到被调用最多、运行时间最长的方法。
  • 离线程序快照:收集程序的运行时配置、线程 dump、内存 dump 等信息建立一个快照,可以将快照发送开发者处进行 Bug 反馈。
  • 其他 plugins 的无限的可能性......

参考:

《深入理解Java虚拟机》

八股文骚套路之JVM(重构完善版)https://mp.weixin.qq.com/s/0XqhneKz7F5A-PE9CdzyoA

https://r2coding.com/#/

https://javaguide.cn/

posted @ 2025-07-10 11:33  兔麻吕  阅读(23)  评论(0)    收藏  举报