dcddc

西米大人的博客

0%

系统学习JVM

JVM 内存区域

知识点

JVM 将内存划分为哪几个区域?每个区域的作用是?

  • 主要是堆空间、方法区、虚拟机栈区
  • 堆空间存储对象实例数据。方法区存储类元数据,例如静态变量。虚拟机栈区是线程维度,按方法维度为每个方法在栈上分配一个栈帧

每个栈帧分为哪几个区域?每个区域的作用是?

  • 局部变量表:存储局部变量。操作数栈:用于计算。动态链接:存储该方法在方法区的符号引用,用于确定方法的直接引用。

堆中为对象分配内存有哪两种方法?与什么因素有关?

  • 指针碰撞和空闲队列。堆内存规整,即 JVM 垃圾回收时做了整理操作,可用指针碰撞法,否则使用空闲队列法。

堆中为对象分配内存空间如何保证线程安全?

  • CAS 重试或 TLAB,提前为线程分配一块内存空间

对象在内存中包含哪几个区域?对象头存储哪些数据?

  • 对象头、实例数据、对齐填充。对象头存储 MarkWord(分代年龄、hashcode、线程相关信息)、类型指针。

访问对象有哪两种方式?各自的优缺点是?Hotspot 虚拟机使用哪种方式?

  • 句柄访问、直接访问。Hotspot 使用直接访问。

内存区域划分

程序计数器

线程私有的内存空间,如果线程执行的是JAVA代码,计数器记录的是将要执行的字节码指令地址,如果是Native方法,计数器值为空。
程序计数器在线程切换时将里面的指令地址缓存在CPU的缓存里。等下次线程重新被调度执行时,CPU从缓存再读到自己的程序计数器里就能继续向下执行线程

虚拟机栈

虚拟机栈是线程私有的,每创建一个线程,虚拟机就会为这个线程创建一个虚拟机栈,虚拟机栈的生命周期和线程是相同的

虚拟机栈表示 Java 方法执行的内存模型,每调用一个方法就会为每个方法生成一个栈帧(Stack Frame),用来存储局部变量表、操作数栈、动态链接、方法出口(调用方程序计数器)等信息

每个方法被调用和完成的过程,都对应一个栈帧从虚拟机栈上入栈和出栈的过程

方法执行完成后可能执行的操作包括:

  • 恢复调用方所属栈帧的局部变量表和操作数栈
  • 返回值压入调用方栈帧的操作数栈
  • 调整调用方程序计数器的值,指向调用方的下一条指令地址

局部变量表

局部变量表存储方法参数和方法内定义的局部变量

局部变量表的大小在编译时就已经确定。存储变量的最小内存单元称为 slot,每个 slot 占 4 字节,对 64 位机器,JVM 会以高位对齐的方式分配两个连续 slot 空间(long、double 本身需要 8 字节,占据两个 Slot)

运行期间不会改变局部变量表的大小

访问局部变量表使用 slot 索引

操作数栈

顾名思义,在方法内部执行计算时用到操作数栈,是一个 LIFO 栈

栈深度在编译时就已经确定

虽然概念上,不同栈帧是相互独立的,但 JVM 在实现里做了一些优化,让下栈帧的部分操作数栈与上栈帧的部分局部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递了

动态连接

每个栈帧都包含一个指向运行时常量区该栈帧所属方法的引用,目的是支持方法调用过程中的动态连接(运行时确定方法符号引用的直接引用)

返回地址

返回地址为调用方下一条指令地址,在方法执行完后,将返回地址写入程序计数器,使得线程可以继续执行调用方后面的指令

每个方法被调用和完成的过程,都对应一个栈帧从虚拟机栈上入栈和出栈的过程

方法执行完成后可能执行的操作包括:

  • 恢复调用方所属栈帧的局部变量表和操作数栈
  • 返回值压入调用方栈帧的操作数栈
  • 返回地址写入程序计数器,让调用方继续向下执行指令

各个线程共享,存放对象实例

堆中没有空间分配对象实例,就会抛出 OutOfMemoryError

方法区

各个线程共享,存储类信息、常量、静态变量等数据

方法区没有内存空间可分配时,就会抛出 OutOfMemoryError

运行时常量池

属于方法区的一部分

类加载时,将 class 文件常量池里的字面量和符号引用放入运行时常量池

在类加载的解析阶段,会将一部分符号引用替换为直接引用

直接内存

DirectMemory,并非 JVM 启动后占用的内存,可以简单理解为 Native 使用的内存。这部分内存在应用程序使用 NIO 技术时会被使用到。

NIO 可以通过 Native 方法直接分配 DirectMemory,然后通过在堆中的 DirectByteBuffer 对象作为这块内存的引用来进行操作。这样能在一些场景中显著提高性能,因为避免了在 JAVA 堆和 Native 堆中来回复制数据。

特别需要注意,DirectorMemory 内存不足时,JVM 也会抛出 OOM!不过 FullGC 时也会对这部分空间进行垃圾回收,但区别是当这块内存不足时,不会主动触发 GC

对象在 JVM 的创建过程

  • 方法区中判断类是否已被加载,如果未加载,先进行类加载
  • 基于类信息在堆中分配内存
    • 分配内存有两种方法:指针碰撞(适用已分配和未分配空间连续,依赖 GC 收集器是否支持压缩过程)、空闲队列(适用于已分配和未分配空间不连续)
      • 假设 JVM 堆中内存是规整的,所有用过的内存放在一边,没用过的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存的过程就仅仅是把那个指针向空闲空间的方向挪动一段与对象大小相等的距离,这种分配方式被称为”指针碰撞”
      • 如果 JVM 堆中的内存不是规整的,使用过的内存空间与未使用的内存空间相互交错,那就没办法进行简单的“指针碰撞”了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,分配的时候在列表中找到一段足够大的内存空间分配给对象实例,并更新列表中的记录,这种分配方式被称为”空闲列表”
      • 可见,选择哪种内存分配方式由 JVM 堆内存是否规整决定,而 JVM 堆内存是否规整又由所采用的垃圾收集器是否有整理过程所决定
    • 分配内存过程需要保证原子性
      • 一种保证原子性的方式是 CAS 重试
      • 另外一种方式是提前为线程预制一块私有内存空间 TLAB(ThreadLocalAvailableBuffer),需要在 java 进程启动中增加-XX:+/-UseTLAB参数来设定
  • 分配完成后,分配到的内存空间会默认初始化为零值
  • 设置对象头信息(包括哈希值、所属类元数据、GC 分代年龄等)

对象的内存布局

分为对象头、实例数据、对齐填充(占位符,满足对象起始地址是 8 字节的整数倍)。重点看下对象头。

对象头

包含两部分。第一部分存储运行时数据,如哈希值、GC 分代年龄、线程相关信息等,官方称为MarkWord。第二部分存储类型指针,即指向类元数据的指针。

访问对象的方式

线程执行过程中,通过栈帧中局部变量表的对象引用来访问堆中的对象实例

JVM 有两种对象访问方式,对应栈帧存储的对象引用有两种情况:

  • 存储句柄的地址
    • 通过句柄访问。句柄池(堆中的一块内存空间)中存储对象实际的地址,线程通过句柄间接访问对象。好处是在对象移动时(GC 时对象位置会移动),只需要改变句柄池中对象的地址,无需改变栈帧中的引用地址
  • 存储对象的地址(HotSpot 使用的就是这种方式)
    • 通过指针直接访问堆中的对象。速度更快。

StackOverflowError 和 OutOfMemoryError

基于上文的描述,JVM 占用的内存空间基本被堆、栈和方法区占据

当栈空间不满足线程执行的内存需求时,会抛出 StackOverflowError,但如果增大栈容量,在多线程情况下,也容易产生内存耗尽 OutOfMemoryError

因此这是一个博弈的过程,在多线程场景下,只能通过减少最大堆和减少栈容量来换取更多线程


垃圾回收

JVM 垃圾回收通常作用于堆空间,因为栈空间在编译期就已经确定内存分配大小,随线程的生命周期创建和回收,不需要考虑运行期动态分配和回收问题。而方法区回收性价比低。

知识点

引用计数器法如何判断对象是否存活?该方法有什么问题?

  • 对象被引用,计数+1,引用失效,计数-1。循环引用的对象无法被 gc

可达性分析法如何判断对象是否存活?哪些节点可以作为 GC ROOTS?

  • 枚举 GCRoots,从 ROOTS 节点出发可达的对象判断为存活。栈帧中局部变量表里引用的对象、方法区中的全局对象可作为 GCRoots

什么是强引用、软引用、弱引用?他们与 GC 有什么关系?

  • new 出来的对象是强引用。强引用指向的对象不会被 GC。只被软引用 SoftReference(对象)指向的对象,如果内存空间不足时会被 GC。只被弱引用 WeakReference 指向的对象,下次 GC 时被回收。

GC 的标记清楚算法有什么问题?

  • 内存碎片化

什么是 GC 的复制算法?该算法有什么问题?

  • 内存均分为两部分,只在一半的内存空间分配对象,GC 时将存活的对象复制到另一半空间,再清空之前使用的那一半空间

HotSpot 使用什么 GC 算法?为什么要有两个 survivor 区?

  • 改进版的复制算法。将内存空间分为 Eden 区和两个 Survivor 区,比例 8:1:1。只在 Eden 区分配内存,GC 时将 Eden 取和 Survivor 区存活的对象复制到另一个 Survivor 区,再清空这两部分空间
  • 两个 Survivor 区避免 GC 产生内存碎片化。假设只有一个 Survivor,GC 时必然产生碎片化,Eden 区存活的对象复制到 Survivor 区还是不能填补这些碎片空间。

为什么 GC 时,JVM 要停止所有线程?

  • 因为保证枚举的 GCRoots 准确性的前提是获取全局一致性的内存空间快照,所以要停止所有线程。

为了避免枚举 GC ROOTS 时扫描全栈,HotSpot 是怎么优化的?

  • 类加载完成后,计算得到对象什么偏移量上存的是引用类型数据
  • JIT 编译阶段,在一些安全点上记录栈帧中哪些位置存的是对象引用,避免全栈扫描

GC 的安全点一般选在什么时候?如何在安全点中断线程?

  • 方法调用、循环跳转、异常跳转
  • 设置中断标志,线程在安全点会主动取这个标志,如果为真就停止线程等待 GC

Parallel Scavenge 收集器有什么优势?CMS 收集器有什么优势和缺陷?

  • 并行收集器服务于新生代。优势是能自适应调节 GC 策略,以提供最大的吞吐量或最合适的停顿时间
  • CMS 收集器服务于老年代。优势是能最大程度减少 FullGC 时间,缺陷是内存碎片化问题、FullGC 和线程运行并行执行导致对象进入老年代时空间不足、和业务线程共同竞争 CPU

不同语言的垃圾回收方式

  • C\C++\汇编语言,他们都需要程序员自己手动释放内存,因此开发效率不高,且容易出现野指针(变量指向的内存空间数据被修改或回收)。但执行效率高,因为不需要单独运行垃圾回收线程。
  • java\go\python等语言,引入了GC(垃圾回收器)概念,运行专门的垃圾回收线程。因此程序员只负责分配内存,开发效率更高,但执行效率不高。
  • rust是一种既能保证较高执行效率,且程序员不用关心垃圾回收的语言。它提出一种ownership思想,堆上分配的对象只会被线程栈帧上的一个变量指向,因此栈帧出栈时就可以垃圾回收。但rust的学习门槛很高。

定位垃圾对象的算法

如果对象不再被引用,则认为是垃圾对象,GC时被回收。Java使用根可达算法判断对象是否被引用,python使用引用计数。

  • 引用计数
    • 对象被引用+1,引用失效-1,简单高效
    • 无法解决相互循环引用问题(A、B都不会被其他对象访问,但A和B互相持有对方引用,造成GC无法回收A、B)
  • 根可达算法
    • 一些GC ROOTS节点作为起点,当所有引用链都无法触达对象时,该对象可被GC。HotSpot使用这种机制。
      • 可被作为GC ROOTS节点的对象包括:栈帧的局部变量表中引用的对象、方法区中引用的常量、静态对象

注意,上述机制如果判断对象不被引用后,JVM 在 GC 前还会执行对象的 finalize 方法(只会执行一次,下次 GC 时不会再次执行),如果方法里对象重新被引用,则不会被 GC,否则被 GC

对象引用类型

对象引用类型不同,垃圾回收的时机也不同。

  • 强引用(描述特别有用的对象),类上没有特殊声明,创建的都是强引用对象,只有对象不被引用时才被GC
  • 软引用SoftReference(描述有用但非必要的对象)指向的对象在将要发生OOM时才会被GC,即使这时还被引用
  • 弱引用WeakReference(描述非必要的对象)指向的对象在下一次GC时会被回收,即使这时还被引用
  • 虚引用PhantomReference指向的对象在对象被GC时,引用方能收到系统通知

垃圾回收算法

JVM对内存空间使用分代模型划分为年轻代和老年代,年轻代的垃圾回收称为YGC,老年代称为FGC。且年轻代和老年代使用的垃圾回收算法不同。

标记清除

对标记为垃圾的对象原地回收,清除后产生大量内存碎片,内存空间利用率低。因此实际不使用这种算法。

复制

年轻代的GC算法。

原始的复制算法将内存等分成两部分,新创建的对象只在其中一半的区域分配内存,回收时将存活的对象复制到另一半空的内存,然后清除之前分配内存的一半空间。问题是永远只能利用50%的内存空间,太浪费。

但实际生产中,98%的对象都会在下次GC时被回收。因此复制算法做了改进。将内存空间分为Eden区和两个Survivor区,比例是8:1:1。每次使用Eden区和其中一个Survivor区,新对象在Eden区分配内存,每次GC时将Eden和Survivor区还存活的对象复制到另一个Survivor区,再清空Eden区和上次使用的Survivor区。
使用两个Survivor区的作用是避免内存碎片化。假设使用一个,每次gc,回收survivor区的对象会产生碎片,把eden区存活的对象复制到survivor区无法插入这些碎片化的内存空间里,造成内存碎片化。

如果GC时存活下来的对象超过Survivor空间容量(10%),或者存活对象年龄达到阈值,对象被移动到老年代。

标记压缩

老年代的GC算法。

标记清除完之后,将所有的存活对象压缩到内存的一端。避免了碎片的产生,但性能较差,因此适用于老年代。因为老年代不会频繁GC,年轻代因为要频繁为对象分配内存,所以会频繁GC,因此更适合复制算法,性能更好。

HotSpot对根可达算法的性能优化

HotSpot是最常用的JVM。

枚举GC ROOTS

上文说过,Java定位垃圾对象使用根可达算法。该算法必须保证在一致性的快照中进行。否则定位的准确性无法保证,因此在定位垃圾对象时需要停止所有线程,即STW(stop the world)

根可达算法需要找到GC时刻所有的GC ROOTS,如果全栈扫描所有栈帧来获取对象引用以及遍历方法区获取全局引用,效率太低

  • 类加载完成后,计算得到对象什么偏移量上存的是引用类型数据
  • JIT 编译过程中,在特定位置(某些指令执行处,称为安全点)记录下栈帧中哪些位置是引用,避免全栈扫描
    • 这些记录存在OopMap(ordinary object pointer)这种数据结构中,GC 时通过 OopMap 可以快速枚举所有 GC ROOTS
    • 设置安全点也是为了减少 OopMap 的存储开销,不可能每个指令都对应有 OopMap

安全点

可以理解为,JVM 只会在安全点执行 GC,而安全点选取的多少对程序运行有直接影响。安全点太少的话 GC 间隔太久,太多影响程序性能(GC 时停止所有线程)。

安全点的选取准则是具有让程序长时间执行的特征,换句话说需要在程序执行告一段落时才选取为安全点。因此一般选取方法调用、循环跳转、异常跳转这些指令处作为安全点。

既然 GC 时需要中断所有线程,那 HotSpot 是如何实现呢?答案是需要 GC 时,设置中断标志,线程在安全点会主动取这个标志,如果为真就停止线程等待 GC

JVM垃圾回收器

根据分代模型,JVM将堆空间分为年轻代和老年代,在这两部分空间使用的垃圾回收器各不相同,但一般都是配对使用

Parallel Scavenge && Parallel Old

Parallel Scavenge是年轻代的垃圾回收器,Parallel Old是老年代的垃圾回收器。它们简称PSPO,也称为并行垃圾回收器,使用多线程来执行垃圾回收,适合几个G的内存。

PSPO适合高吞吐量(程序运行时间占总时间的比例)的计算密集型业务,不适合需要快速响应的交互型业务

  • 减少响应时间和提高吞吐量是矛盾的。举个例子,原来10秒GC一次,耗费100毫秒。现在5秒GC一次,耗费70毫秒,虽然线程STW的时间减少了,响应速度更快,但吞吐量反而更低了

ParNew && CMS

ParNew是年轻代垃圾回收器,CMS是老年代。ParNew仍然使用多线程做垃圾回收,不用PS是因为和CMS不适配。这一组垃圾回收器也适合于几个G的内存。

CMS(concurrent mark sweep)是一款以获取最短GC时间为目标的收集器,适合需要快速响应的交互型业务。它也使用标记整理算法做垃圾回收,不过在识别垃圾对象阶段和清除垃圾对象阶段,都能和业务线程并发执行,即不需要STW。它的GC流程为:

  • 初始标记(标记GC ROOTS)
    • 需要STW,但耗时短
  • 并发标记(GC ROOTS TRACING)
    • 和业务线程并发执行,不需要STW
    • 使用三色标记算法判定对象是否为垃圾对象。它的思想和根可达算法一样,也是顺着ROOT对象往下找,只不过会将对象区分为三类,以便并发标记线程下次被调度时知道该从哪些对象开始继续查找垃圾对象
  • 重新标记
    • 需要STW,修复并发标记阶段对一些对象是否可回收的错判
  • 并发清除
    • 和业务线程并发执行,不需要STW

当然,CMS也有一些缺陷:

  • CPU资源敏感,如果用户线程吃CPU资源(计算密集型服务),使用CMS会因为并发竞争CPU资源而降低用户程序性能

G1

随着内存越做越大,几十G的内存出现后,采用分代模型的垃圾回收器,做一次YGC的STW时间越来越久,因此诞生了不使用分代模型的G1垃圾回收器。它适合几十G的内存。

G1使用分区的思想,将内存划分为很多块小的区域。虽然G1物理上对内存没有使用分代模型来划分,但是逻辑上依然使用了分代的思想,将每块区域设置为Eden区、Survivor区和Old区的一种。因为年轻代老年代的内存空间变小了,所以GC的速度自然就变快了,当然GC的频率也肯定更高。

JVM调优

JVM启动参数

  • -Xms4g
    • 最小堆大小,例如设置为4g
  • -Xmx4g
    • 最大堆大小,例如设置为4g
    • 最小堆大小和最大堆大小通常设为一样,避免扩缩容产生内存抖动。生产环境中建议4G
  • -Xmn2g
    • 年轻代大小,例如设置为2g
  • -XX:SurvivorRatio=8
    • 年轻代Eden和Survivor的比值。注意有2个Survivor,所以如果设为8,表示8:1:1
  • -XX:MaxDirectMemorySize=1g
    • 最大直接内存,单位字节。NIO中ByteBuffer会用到直接内存实现零拷贝来提升IO性能
  • -XX:+UseConcMarkSweepGC
    • 使用CMS,这时也会伴随设置年轻代垃圾回收器:-XX:+UseParNewGC
  • -XX:+PrintGC
    • 打印GC日志
  • -Xloggc:/home/admin/me/logs/gc.log
    • GC日志文件
  • -XX:+HeapDumpOnOutOfMemoryError
    • OOM时自动生产堆转储文件

JVM调优常用命令

  • jps
    • 查询所有JAVA进程和对应的进程id
  • jinfo ${java进程id}
    • 查询某个JAVA进程的schema,例如系统配置、JVM启动参数
  • jstack ${java进程id} | more
    • 查询某个JAVA进程的所有线程信息,包括线程名、优先级、线程状态和调用堆栈
  • top
    • 查询运行的进程占CPU和内存的情况,按占用CPU由大到小排序
  • top -Hp ${java进程id}
    • 查询某个Java进程中的线程占CPU和内存的情况,按占用CPU由大到小排序
  • jmap -histo ${java进程id} | head -20
    • 按类的维度查询类产生了多少个实例对象以及占内存大小,按占据内存大的取前20个
    • jmap会产生STW,生产环境不能用
  • jmap -dump:format=b,file=xxx.hprof
    • 把JVM的整个堆转储为一个文件,再使用工具对文件做离线分析
    • 堆文件分析工具:jvisualvm(JDK自带)、jhat
    • jmap会产生STW,生产环境不能用
    • 生产环境设置JVM启动参数“-XX:+HeapDumpOnOutOfMemoryError”

如何模拟实际生产环境做JVM调优

上面说的jmap命令会产生STW,影响接口性能,生产环境不能用,所以需要模拟实际生产环境,以便执行jmap做堆分析。具体有这么几种方式:

  • 在测试机上做压测
  • 在生产环境的负载均衡中剔除掉问题机器
  • 用tcpcopy把线上流量打到测试机,模拟线上真实流量

arthas

下载arthas:curl -O https://arthas.aliyun.com/arthas-boot.jar
执行arthas:java -jar arthas-boot.jar,然后选择将arthas挂载到哪个java进程

arthas常用命令:

  • dashboard
    • 查看堆内存、GC、线程的CPU占用率等信息
  • thread
    • 查看所有线程信息,包括CPU占用率、线程状态等
    • thread -b查看是否有线程发生了死锁
  • jvm
    • jinfo类似,查看虚拟机相关信息,包括虚拟机名称、厂商、类路径、使用什么垃圾回收器等
  • jad ${类名}
    • 反编译类,还能输出类在classpath下的路径和类使用的加载器
  • redefine ${class文件路径}
    • 热部署class类。如果不想修改类后重新部署整个工程,可以在IDEA修改类后”Build-recompile xx.java”来本地编译类文件,然后用redefine命令执行热部署
  • trace ${类目} ${方法名}
    • 跟踪一个方法内部各个调用的执行时间

JVM调优经验

  • 尽量避免使用大内存(超过4G,需64位JDK来支持)。这可能造成FullGC特别耗时(堆空间太大)、发生OOM时无法Dump堆快照(堆空间太大,dump文件几十G,难以存储和分析)、内存资源较32位机器而言消耗更多
    • 解法是创建多个32位(最大4G内存)虚拟机,保证FullGC的停顿时间可控
  • 存在大量NIO操作时,如果DirectMemory内存空间较少,也可能产生OOM
  • 用户程序中不要调用Runtime.getRuntime().exec(),该方法会创建进程,频繁创建进程非常消耗CPU、内存资源
  • 存储大量数据时不要使用HashMap,内存利用率太低,容易占满堆空间产生OOM

类加载

知识点

类加载发生的时机有哪些?

  • new、Class.forName 反射调用、访问类的静态变量或静态方法

类加载包括哪几个阶段?每个阶段的作用是?

  • 加载,把.class 字节码加载到 JVM,在方法区生成 class 对象并分配内存空间。
  • 验证,验证加载的字节码的安全性
  • 准备,为类的静态变量赋初值
  • 解析,将方法区的符号引用替换为直接引用
  • 初始化,执行静态代码块,并将类的静态变量赋实际的值

什么是静态解析和动态连接?

  • 静态解析,指在类加载阶段将方法的符号引用替换为实际的调用地址。动态链接指在运行期间确定并替换方法的符号引用为实际的调用地址

什么是非虚/虚方法?哪些方法是非虚/虚方法?

  • 非虚方法可以静态解析,虚方法只能动态链接。非虚方法包括静态方法、私有方法,虚方法指实例方法

什么是解析调用?什么是分派调用?

  • 调用非虚方法,称为解析调用。调用虚方法,称为分派调用

为什么说 JAVA 是一门支持静态多分派、动态单分派的语言?

  • 所谓静态多分派,即需要知道多个宗量才能在编译期确定要调用的虚方法。典型应用就是方法重载。所谓动态单分派,指在运行期才能根据单宗量确定要调用哪个虚方法。典型应用就是方法重写。宗量指的是方法调用者和方法参数。

什么是双亲委派模型?作用是?

  • 优先委托父加载器进行类加载,称为双亲委派模型。作用是实现了 Java 自带的核心类库只会被 JVM 提供的 bootstrap 和扩展类加载器加载,保证 Java 应用的安全性

线程上下文加载器的作用是?有什么使用案例?

  • Spi 应用场景中,核心类要加载第三方应用的实现类,而核心类是用父级加载器加载的,因此按双亲委派模型,无法加载到第三方应用的实现类。这时可以通过线程上下文加载(应用加载器)来加载,打破双亲委派模型。应用场景:1、JDBC 加载第三方实现的驱动 Driver 类,使用 ServiceLoader 实现,它内部使用的就是线程上下文加载器。2、Spring 框架加载应用里定义的 bean,也使用了线程上下文加载器。

Tomcat 使用哪些类加载器?他们各自的作用和相互之间的父子关系是?

  • commonCL:加载 tomcat 容器和 web 应用公共的类。catalinaCL:加载只被 tomcat 容器使用的类,对 web 应用不可见。sharedCL:加载所有 web 应用公共的类。webappCL:加载 web 应用自身的类。
  • tomcat 类加载器使用标准的双亲委派模型实现类加载和隔离。commonCL 是 catalinaCL 和 sharedCL 的父加载器。sharedCL 是 webappCL 的父加载器。

类加载的几个阶段

JAVA语言在运行期执行类加载

类在JVM的生命周期为:加载->验证->准备->解析->初始化->使用->卸载,其中“加载->验证->准备->解析->初始化”称为类加载阶段

通常发生以下操作时(JVM称之为对类的主动引用),如果类还未加载,会触发类加载流程

  • new一个对象
  • 类的静态属性或方法被访问(非final类型,final类型在编译期已经优化到常量池中访问,不会触发类的初始化)
  • 对类进行反射调用
  • 子类被初始化前会触发父类初始化

下面具体介绍类加载每个阶段的功能

加载

基于类的全限定名获取类的class文件字节码(由类加载器实现),JVM将字节码转化为静态数据结构存储在方法区内,最后在堆中生成一个Class对象供用户调用

  • 字节码包含类的元信息、静态变量、常量(例如final变量值和符号引用)。其中常量存储在运行时常量池
    • JDK8以前,使用“永久代”这种堆内存作为方法区
    • 从JDK8开始,使用“元空间”这种直接内存作为方法区,且只存储类的元信息,将类的静态变量和运行时常量池都放到了堆中
  • 加载前JVM会验证字节码文件格式,例如著名的4字节“cafe babe”魔数

程序员可以通过自定义类加载器来灵活控制获取class字节码的方式:

  • zip压缩包获取,jar、war包里的类就是这种加载方式
  • 网络获取
  • 运行时计算生成字节流,动态代理就是这种加载方式

当同一个类由两个不同类加载器加载,这两个类认为是不相等的(equals、isAssignableFrom、instanceOf 等方法都返回 False)

验证

虽然JAVA编译后生成的字节码class文件,虚拟机加载它相对安全,但class文件依然可以不由java源码编译生成,甚至可以直接十六进制编写字节码,因此虚拟机仍然很有必要对字节码进行验证,保证JVM能安全稳定地运行

  • 这里的验证更多是对数据安全性的验证,上面说的加载阶段是对字节码格式的验证

准备

为类的静态变量分配内存并赋默认值

  • 如若同时被final修饰,准备阶段就会分配从常量池获取的值

解析

解析阶段将运行时常量池内的符号引用转化为直接引用,也称为静态解析。如果此时不能转为直接引用,需要在运行期再根据调用栈中的实际内存地址替换符号引用,称为动态连接。Java的多态底层就是用动态连接实现的

  • 符号引用:描述引用的类和接口的全限定名的字符串
  • 直接引用:引用的类和接口的实际内存地址
    • 类的静态方法、私有方法、构造方法、final方法可以在类加载阶段直接确定内存地址,即可以静态解析。这些方法也称为非虚方法

初始化

为类的静态变量赋值,执行类的静态代码块。注意构造方法不会在这里执行,只有new对象时才会执行构造方法

双亲委派模型

类加载逻辑遵循双亲委派模型,即加载 class 时,所有的类加载器都会先委托父加载器进行类加载,只有父加载器加载失败,才会自己尝试加载类。这种设计保证了 jdk 的核心类只会被启动加载器 bootstrap 加载,保证了 java 应用程序的安全

类加载器的层级结构从父到子依次是:bootstrap启动加载器-扩展加载器-应用加载器-用户自定义加载器

双亲委派模型的实现写在 ClassLoader 的 loadClass 方法,用户实现的自定义类加载的加载逻辑需要重写 findClass 方法,该方法在 loadClass 方法里被回调

双亲委派模型使得类加载满足先父后子这样的加载层级关系,但这种关系也是一种约束。例如 JNDI 服务的代码由 bootstrap 加载,但代码里又需要加载业务方的 SPI,显然 bootstrap 是加载不到的,只能由应用加载器来加载。因此 JAVA 引入了线程上下文加载器,通过当前线程来获取,默认就是应用加载器。因此通过线程上下文加载器,就可以加载 SPI,但这也打破了双亲委派模型的原则

JNDI:Java 命名与目录接口(Java Naming and Directory Interface),简单理解 JNDI 服务的作用就是通过配置文件对资源(例如 MySQL 链接参数、业务方 SPI 实现类路径)进行命名,然后再根据名字来找资源

类加载案例

tomcat 类加载器架构

tomcat 的类加载架构:使用正统的双亲委派模型实现,父子关系描述如下:

  • common 类加载器
    • catalina 类加载器
    • shared 类加载器
      • webapp 类加载器

common 类加载器的父加载器是应用加载器。

common 类加载器负责加载所有 web 应用和 tomcat 容器共用的类库。这些类库放置在/common 目录下

catalina 类加载器负责加载只被 tomcat 使用的类库,对所有 web 应用不可见。这些类库放置在/server 目录下

shared 类加载器负责加载被所有 web 应用共享的类库。这些类库放置在/shared 目录下

webapp 类加载器负责加载尽可以被 web 应用使用的类库。这些类库放置在/WebApp/WEB-INF 目录下。如果有多个 web 应用,就存在多个 webapp 类加载器

如果多个 web 应用都共用 Spring 框架,那么对 Spring 框架的加载会交给 shared 类加载器或 common 类加载器,但 Spring 要加载 web 应用的类,那么如何加载不在其加载范围的类库呢?其实就是通过上面介绍的线程上下文加载器来获取 webapp 类加载器

OSGi

OSGi 中的模块(Bundle)类似 Java 类库,都可以打成 Jar 包。区别是 Bundle 中可以声明依赖的 package 也可以声明导出的 package,且每个 Bundle 有自己的类加载器,Bundle 间的类加载器是平级关系,一个 Bundle 加载器既可以被其他 Bundle 加载器使用,也可以使用其他其他 Bundle 加载器。因此可以实现模块级的热插拔功能,但也增加了类加载的复杂度,容易产生死锁。

OSGi 类加载产生死锁:BundleA 和 BundleB 类加载时互相依赖彼此的类加载器,且 loadClass 是一个同步方法,会对类加载器加锁,所以这两个类加载器都在等待对方处理自己的类加载请求,产生死锁。

静态代理与动态代理

JDK 动态代理

动态代理在运行时使用字节码生成技术生成代理类。

参考:
https://blog.csdn.net/Scplove/article/details/52451899
https://blog.csdn.net/zhangqiluGrubby/article/details/61919622

JDK 动态代理 :委托类必须实现接口,对没有实现接口的类不能 JDK 动态代理。

委托类被动态代理的方法包括:接口方法、hashCode、equals、toString

动态体现在:

  • 运行时动态生成代理类$ProxyN
  • 代理类不与委托类有任何绑定关系,代理类可以代理任意委托类(委托类必须实现接口),因此可以灵活复用于各种委托类的应用场景中。这是和静态代理最本质的区别

应用场景:

  • Spring 使用动态代理对 bean 增强

除了 jdk 动态代理外,还有一种动态代理技术:CGLIB 动态代理。它针对类来实现代理,对指定目标类产生一个子类,通过方法拦截技术,拦截所有父类方法调用。

静态代理

所谓静态,即代理类和委托类的代理关系在编译前就已经确定。

代理类和委托类都实现相同接口或继承相同的父类,用户手动实现代理类,通过聚合的方式在代理类中聚合委托类,实现接口代理。

动态语言

这一块只做了解。

定义:在运行期进行类型检查的语言称为动态语言。相对的,编译期进行类型检查的语言称为静态语言。JAVA 是静态语言

动态语言和静态语言的另一个显著区别是:动态语言的变量无类型,变量值才有类型(联想 JS)

JVM 对动态语言的支持一直存在缺陷,主要表现在方法调用上

JVM 方法调用的第一个参数必须是方法的符号引用,而符号引用在编译期确定,即编译期就需要知道调用方类型,这与动态语言在编译期不 care 类型的特点冲突。动态语言在运行时才能真正知道方法调用方的类型。

JDK 从 1.7 开始真正支持动态语言特性。

  • 在方法调用时,提供了一种不依赖符号引用确定目标方法的机制,称为 MethodHandle。可以在运行期才能确定调用方类型。

编译期优化

知识点

伪泛型、真实泛型的含义?类型擦除和类型膨胀指的是?

  • Java 使用伪泛型,编译期间将泛型类的类型转化为原始类型,称为类型擦除。访问泛型类时,强转成传入的实际类型。
  • 真实泛型在编译期不做类型擦除,使用到的泛型类会编译为真实类型,称为类型膨胀。

泛型的类型擦除

泛型的本质是参数化类型

Java 的泛型是伪泛型,Java 的泛型基本上都是在编译阶段实现的

之所以是伪泛型,因为类型擦除特性:

在生成的字节码中是不包含泛型中的类型信息的,使用泛型的时候加上类型参数,在编译器编译的时候会去掉,替换为原始类型,这个过程称为类型擦除

作为编译期类型擦除补偿,编译期对涉及访问泛型类型对象的代码处做了增强,增加了强制类型转换来保障结果符合预期。同时,JAVA 在代码编译前会进行泛型类型检查

与伪泛型相对的是真实泛型,编译后不会类型擦除,使用泛型的类会编译为真实泛型类型,运行时也会加载为真实泛型类型对应的泛型类,这也称为类型膨胀

举个例子,List和 List泛型擦除后都只是 List 类型,而一些使用真实泛型的语言,编译期不做类型擦除,运行时会生成两个类,分别是 List和 List

运行期优化

知识点

为什么说 JAVA 程序是可移植跨平台的?

  • JVM 运行在物理机之上,提供了自己的指令集,与硬件无关,因此是可移植跨平台的

解释执行和编译执行的特点各是什么?

  • JVM 执行引擎支持解释执行和编译执行。解释执行可直接执行 java 编译后的字节码,编译执行指将字节码做 JIT 编译为硬件平台可识别的机器码后执行。
  • 解释执行因为无需二次编译,因此可快速启动 Java 程序,编译执行主要用于提高热点代码的执行效率

为什么编译执行性能优于解释执行?

  • 机器码可直接被底层硬件平台识别和执行。解释执行需要翻译字节码,再执行 JVM 指令,底层还是要执行硬件平台的指令
  • JVM 在 JIT 编译阶段做了性能优化,而代码编译阶段几乎没有任何优化

JIT 如何探测热点代码?

  • 基于计数器的热点探测,当方法调用次数超过阈值会触发 JIT 编译,生成机器码,后续调用该方法时,JVM 做编译执行而非解释执行

为什么 JIT 的即时编译又称为 OSR(栈上替换)编译?

  • JIT 编译后,方法的调用地址改变,但此时该方法的旧地址还在栈帧上,因此需要替换,所以 JIT 编译又称为栈上替换

JIT 在编译阶段对代码做了哪些优化?

  • 方法内联,逃逸分析

什么是方法内联?有什么好处和局限性?

  • 方法内部调用其他方法时,将其他方法的代码“复制”到方法内,避免额外创建栈帧。局限性是对于虚方法,可能存在多态,无法确定调用方法的版本,因此很难做方法内联。

虚方法无法内联的解决方案是什么?

  • 继承关系分析技术。不存在多态,可以内联。存在多态,缓存方法的调用方,如果每次调用不变,则可以内联,否则无法内联,到虚方法表查找真正的调用地址

有哪两种类型的逃逸分析?

  • 方法逃逸分析,判断方法内部创建的对象是否会被外部方法引用。线程逃逸分析,判断对象是否会被外部线程访问。

基于逃逸分析的优化方案有哪些?

  • 栈上分配、标量替换:如果不存在方法逃逸,对象可在栈帧上分配,不用在堆上分配,减轻 GC 压力。栈上分配时,只分配对象被访问到的成员变量,而非整个对象,称为标量替换
  • 同步消除:如果不存在线程逃逸,则不存在读写竞争,可以消除对象的同步锁

JVM 执行引擎

JVM 运行在物理机之上,可以自定义一套指令集,因此能执行那些不被硬件直接支持的指令

JAVA 代码编译过程概括为:词法、语法分析–>抽象语法树 AST–>字节码指令流

编译生成的指令流是一种基于栈的指令集架构,指令通过操作数栈执行。相比于另一种直接依赖硬件来执行指令的寄存器指令集,基于栈的指令集优点是可移植,缺点是性能不如寄存器指令集

JVM 的执行引擎负责执行字节码指令流,执行的方式有两种:解释执行编译执行

解释器能快速启动和执行被编译成字节码的 JAVA 程序,编译执行是由及时编译器JIT将字节码编译成机器码后执行,可以理解为在执行时二次编译了,目的是为了提升热点代码的执行效率

主流的 JVM 同时配有解释器和编译器,二者互相配合。且当编译器进行一些“激进优化”失败后,可以逆优化回退到解释执行

JIT 即时编译器

JIT 的作用:为了提高热点代码执行效率,运行时 JIT 将这些代码编译成与本地平台相关的机器码

Hotspot 使用基于计数器的热点探测来甄别热点方法和循环体(热点循环体会触发所在方法的 JIT 编译)。当方法调用次数超过阈值,会触发 JIT 即时编译,下次调用时,方法的入口地址会替换为新的

因为循环体热点探测发生在方法执行过程中,因此触发的即时编译动作也称为OSR编译(栈上替换,方法栈帧还在栈上,方法就被替换了)

编译执行比解释执行更快的原因有两点:

  • 虚拟机翻译字节码产生的额外耗时
  • JVM 对代码的优化都做在了 JIT 即时编译阶段,代码编译阶段几乎没有任何优化措施

JIT 编译优化

JIT 编译阶段的代码优化有哪些呢?

方法内联

什么叫方法内联?

  • 方法内联就是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用

方法内联有什么好处?

  • 消除方法调用的成本,如无需再建立新的栈帧
  • 为其它优化手段建立良好的基础,如无用代码消除等

方法内联的局限?

  • 非虚方法在编译期就可以确定方法版本,但虚方法如果存在多态,很难确定调用的方法版本。因此虚方法很难在 JIT 编译期做方法内联

虚方法无法内联的解决方案

  • 使用CHA(类型继承关系分析)技术对整个应用做类型分析
    • 对于虚方法,查询 CHA 后得到方法只有一个版本,即不存在多态,则可以内联
    • 对于虚方法,查询 CHA 后得到方法有多个版本,即存在多态,则通过内联缓存来实现内联:在方法调用时记录下调用方,如果每次虚方法调用方不变,则内联可以继续。否则,取消方法内联,查找虚方法表进行方法分派
    • 虚方法内联的解决方案属于激进优化,需要预留逃生门,当方法内联不成立时,先回退到解释执行

逃逸分析

逃逸分析与方法内联用到的 CHA(类型继承关系分析)一样,虽然不是直接优化的方法,但可以作为其他优化手段的分析技术

逃逸分析的目标是对象作用域,存在两种对象逃逸

  • 方法逃逸:当一个对象在方法中定义之后,作为参数传递到其它方法中;
  • 线程逃逸:如类变量或实例变量,可能被其它线程访问到;

如果对象不存在方法逃逸和线程逃逸,可以进行一些优化:

  • 栈上分配。不会逃逸出方法之外的对象可以在栈上分配内存。对象随方法结束栈帧回收而被销毁。不用在堆上分配,减轻 GC 压力。
  • 标量替换。如果一个对象不会逃逸出方法外,可以只在栈上创建它被访问到的成员变量,不用在栈上创建整个对象。标量的含义就是不可再分解的变量,一般就是对象的类型为基本类型的成员属性。
    • 目前 hotspot 虚拟机对不存在方法逃逸的对象,实际就是用标量替换作为栈上分配的实现方式
  • 同步消除。不会逃逸出线程的对象,不会产生读写竞争,可以消除对它的同步锁

JAVA 内存模型

知识点

什么是缓存一致性协议?

  • 多核 CPU 中,每个 CPU 各自高速缓存里的共享(公共)变量需要保证数据一致性

MESI 缓存一致性协议的实现原理是?

  • CPU 修改高速缓存里的共享变量后,先发送该数据 Invalid 的通知到其他 CPU 的高速缓存,收到响应后再写到内存
  • 高速缓存读取变量时,如果状态是 Invalid,重新从主内存读取

缓存一致性协议为什么不能保证线程安全?

  • 修改共享变量后,需要同步等待 Invalid 通知的响应后,才能写回内存,拖累了 CPU 的性能,因此 CPU 将同步改成异步,先将计算后的公共变量写到“store buffer”,然后发送 Invalid 通知就结束了。等收到 Invalid 响应后,再把数据从“store buffer”写回内存。而其他 CPU 的高速缓存也不会立刻执行 Invalid,而是将 Invalid 请求放入队列后就返回响应了。
  • 引入异步和 store buffer 后,其他缓存读到的共享变量可能为脏数据,因此无法保障共享变量的线程安全性。即,使用异步实现缓存一致性协议后,破坏了可见性。
    • Invalid 通知还在队列里没处理,这时读取的共享变量为脏数据
    • 虽然将共享变量标记为 Invalid,但最新值还没来得及从 store buffer 写入内存,这时读取的共享变量为脏数据。如果是同步的方式,可以认为收到 Invalid 响应后立刻写入内存
  • 引入异步和 store buffer 后,因为共享变量会先缓存在 store buffer 中而非写入内存,所以 CPU 出于性能考虑可能会优先将一些非共享变量的值写入内存,产生指令重排序,因此也无法保障线程安全性

什么是 JAVA 内存模型?作用是什么?

  • Java 内存模型建立了与底层硬件的映射关系,将内存划分为主内存和工作内存(类比高速缓存)。线程只能修改工作内存里的数据,无法直接修改主内存。目的是让 Java 程序在各个平台达到一致的内存访问效果。

从 JAVA 内存模型的角度,如何解释 volatile 可见性的实现原理?

  • 每次访问 volatile 变量,都会从主内存读取最新值到工作内存。每次写工作内存里的 volatile 变量后,都会立刻回写主内存

volatile 底层是如何实现可见性和有序性的?

  • JVM 在 volatile 关键字修饰的变量的读写指令前后加入了内存屏障,效果就是将缓存一致性协议改回同步方式,不会产生读取脏数据问题和指令乱序问题,保障了可见性和有序性

为什么 volatile 不能保证原子性?

  • 以自增操作为例,该操作是非原子操作,包括读取变量、变量自增、变量回写。虽然 volatile 保证读取变量时读到的是最新值,但对于变量做自增操作时,可能有新的值回写到主内存,这时操作的 volatile 变量就过期了,是线程不安全的,所以仅靠 volatile 不能保证原子性

volatile 有哪些应用场景?

  • 用作哨兵,通常是个布尔对象,及时获取其他线程对哨兵状态的变更
  • 单例实例用 volatile 修饰,创建单例时不需要加同步代码块

高速缓存

CPU 的每个核由计算单元 ALU、寄存器(存储当前线程需要做运算的数据)、指令计数器(存储下一条指令在内存中的地址)、高速缓存组成

CPU 在做运算时,先从内存读取数据到寄存器,ALU 再从寄存器读取数据做运算,后者的读取速度比前者快了 100 倍。为了提高 CPU 的利用率,避免浪费大量时间等待从内存读取数据,引入了高速缓存,集成到 CPU 的核上。CPU 每次运算时读取和写入的都是缓存,而不是每做一次运算就读取和写入一次内存,大大提升了 CPU 利用率

缓存一致性

多核 CPU 的每个 CPU 都有高速缓存,因此如果多线程在多核上并发计算同一内存空间上的共享数据,每个核上的缓存都会存储一份共享数据的镜像,需要保证每个 CPU 各自缓存的共享数据的一致性(可见性,别的线程改了我能立刻将最新值读到缓存),因此产生了缓存一致性协议

  • 严格意义上说是“缓存行”的一致性,因为 CPU 缓存是以缓存行为最小单元从内存读取数据的,大小为 64 字节。引入缓存行也是为了提高读取性能,因为从内存读取数据是 IO 操作拖累 CPU 性能,且程序运行过程中大概率会读取相邻数据,所以干脆读取“一行”数据

每个 CPU 厂商有自己的缓存一致性协议实现,最经典的是 MESI 协议。CPU 修改高速缓存里的共享变量后,先发送该数据 Invalid 的通知到其他 CPU 的高速缓存,收到响应后再写到内存。从高速缓存读取变量时,如果状态是 Invalid,重新从主内存读取。可以看出,缓存一致性协议保证了共享变量的可见性。

这种实现方案本质是同步的。修改共享变量后,需要同步等待 Invalid 通知的响应后,才能写回内存,拖累了 CPU 的性能,因此优化方案是将同步改成异步,先将计算后的公共变量写到“store buffer”,然后发送 Invalid 通知就结束了。等收到 Invalid 响应后,再把数据从“store buffer”写回内存。而其他 CPU 的高速缓存也不会立刻执行 Invalid,而是将 Invalid 请求放入队列后就返回响应了。

异步方案的问题是破坏了共享变量的可见性。例如 Invalid 通知还在队列里没处理,这时读取的共享变量为脏数据。或者是将共享变量标记为 Invalid,但最新值还没来得及从 store buffer 写入内存,这时读取的共享变量为脏数据。如果是同步的方式,可以认为收到 Invalid 响应后立刻写入内存。

另外引入异步和 store buffer 后,会产生指令重排序。因为共享变量会先缓存在 store buffer 中而非写入内存,所以 CPU 出于性能考虑可能会优先将一些非共享变量的值写入内存,产生指令重排序。

JAVA 内存模型

JVM 规范中定义了一套JAVA内存模型,目的是屏蔽硬件和操作系统差异,使得 JAVA 程序在各个平台达到一致的内存访问效果

  • JAVA 内存模型主要面向的是堆上对象的读写规则,因为只有堆上对象才会存在多线程竞争问题,栈上对象是线程私有的,不会被共享

JAVA 内存模型划分了主内存工作内存。映射到硬件层面,主内存可以理解为内存区域,工作内存可以理解为高速缓存区域。线程对变量的操作只发生在工作内存,不能直接读写主内存。

JAVA 内存模型主要考虑的是变量在多线程间的可见性以及满足 happens-before 规则的指令有序性

  • 所谓 happens-before 规则,即前面一个操作的结果对后续操作必须是可见的,不能因为指令重排序破坏这个规则
  • 可见性要依赖底层的缓存一致性协议实现。上面提到的异步方案其实保证不了共享变量的可见性,因此 JAVA 提供了 volatile 关键字来强保证共享变量的可见性
    • 实现可见性的前提就是保证有序性,因为如果指令乱序了可能导致 volatile 变量的值不符合代码逻辑,所以即使满足可见性,实际拿到的也是一个错误的结果

volatile

被 volatile 修饰的变量,可以保证可见性和有序性

可见性从 JAVA 内存模型角度可以这么理解:

  • 一般的变量,线程修改后,不会立刻从工作内存写回主内存,需要等线程切换时才回写。线程读取变量到工作内存后,操作的就是工作内存中变量的值,对于之后变量在主内存的更新是感知不到的。即,不立即写主内存,也不实时读主内存
  • volatile 变量在线程的工作内存修改后,会立刻回写主内存。而其他线程每次使用 volatile 变量时,也会去实时读取主内存volatile的值

可见性从缓存一致性协议可以这么理解:
异步方式实现的缓存一致性协议,不会在共享变量修改后同步写内存,且共享变量 Invalid 操作也不会同步执行,因此其他 CPU 计算的共享变量仍有可能是一个脏数据。为了保证 volatile 修饰对象的可见性,JVM 在对象读写前后加入了内存屏障指令,将异步缓存一致性协议改回同步方式,废弃了 store buffer 和 Invalid queue,所以共享变量的修改会实时被其他线程读取到,保证了可见性。

但是 volatile 变量不能保证并发下的原子性。因为做一些依赖volatile变量值的非原子操作(原子操作,可以理解为一条机器码指令)的运算,例如 volatile 变量自增,可能在 CPU 计算 volatile 变量期间,其他线程将 volatile 变量的最新值写回主内存,导致计算前已经读取的 volatile 值成了脏数据,因此基于原 volatile 值做的任何运算也就不能保证是原子操作,是线程不安全的

如果是对 volatile 修饰对象的原子操作或读操作,则可以认为是线程安全的。java 中的原子操作包括:

  • 除了 long 和 double 之外的基本类型的赋值操作,因为 long 和 double 类型是 64 位的,所以它们的操作在 32 位机器上不算原子操作,而在 64 位的机器上是原子操作。
  • 所有引用 reference 的赋值操作
  • java.concurrent.Atomic *包中所有类的原子操作

volatile 的应用场景:

  • 用作哨兵。当线程执行了操作后,通过修改哨兵的布尔值,能立即通知到其他线程
  • DCL 模式创建单例。将单例修饰为 volatile,保证并发下,线程在加锁前能可靠地判断当前时刻单例是否为空

volatile 除了保证变量可见性,还保证有序性(执行时不会进行指令重排序)。因为提升 volatile 变量可见性后,如果允许指令重排序,线程读取的 volatile 变量值可能与代码期望效果不符。可以试想一下 volatile 作为哨兵的例子,指令重排序后可能会提前放哨。

  • 指令重排序:JVM 执行引擎提升性能的一种优化,实际执行的顺序与代码的顺序不一致,但保证最终一致性
  • 禁止指令重排序会消耗写 volatile 变量的性能,但使用 volatile 整体的开销还是比加锁要低。

除了 volatile 关键字,synchronized 和 final 关键字也能保证可见性

  • synchronized 保证可见性的依据:对一个变量 unlock 前,必须先把变量回写到主内存