JVM 详解

本文详细介绍了JVM的内存区域,包括程序计数器、虚拟机栈、本地方法栈、堆、方法区(包括运行时常量池)以及其作用。探讨了对象的创建与访问过程,讲解了垃圾回收器的算法(如引用计数、跟搜索、二次标记、四种主要的垃圾回收算法)以及内存分配策略。同时,文章还涵盖了线程相关的概念,如Java内存模型、线程安全、锁优化等核心知识点。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

JVM详解

一、内存与垃圾回收篇

JVM的整体结构

在这里插入图片描述

Java虚拟机在执行Java程序的过程中会把它所管理内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域伴随着虚拟机进程的启动而存在,比如堆和方法区;有的区域则是依赖用户线程的启动和结束而建立和销毁,比如虚拟机栈、本地方法栈和程序计数器。

其中,堆和方法区是线程共享的区域,虚拟机栈、本地方法栈和程序计数器是线程私有。

1.运行时数据区

1.1 程序计数器

程序计数器(PC)是一块很小的内存空间,它的作用是当前线程所执行的字节码指令的地址,字节码解析器的工作就是通过改变程序计数器的值来选取下一条需要执行的字节码指令

由于Java虚拟机的多线程是通过线程轮流切换分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的位置,每个线程都有一个独立的程序计数器代表当前线程执行指令的位置,各线程之间的程序计数器互不影响,独立储存。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,程序计数器则为空,比如Thread.start()方法,这就是为什么start()方法能启动一个线程,run()不能启动。

1.2 虚拟机栈

与程序计数器一样,虚拟机栈也是线程私有的,它的生命周期与线程的生命周期一致。虚拟机栈描述的是Java方法执行的内存模型:每个方法执行的时候都会创建一个栈帧用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表所需要的内存空间在编译期完成分配,当进入一个方法时,这个方法需要在帧中分配多大的 变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

虚拟机栈有两种异常情况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈无法申请到足够的空间时会抛出OutOfMemoryError异常。

1.3 本地方法栈

本地方法栈与虚拟机栈的作用非常相似,区别是虚拟机栈执行的Java方法服务,而本地方法栈则是为虚拟机使用到的Native方法服务。本地方法栈也会抛出StackOverflowError异常和OutOfMemoryError异常。

1.4 堆

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

堆是垃圾收集器管理的主要区域。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;新生代还可以细分为Eden区、From Survivor空间、To Survivor空间。

堆可以处于物理上不连续的内存中,只要逻辑上是连续的即可。如果在堆中的内存实例分配无法完成,并且堆也无法扩展的时候,会抛出OutOfMemoryError异常。

1.5 方法区

方法区和堆一样,是线程共享的区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

方法区的回收目标是常量池的回收和对类的卸载,当方法区无法满足内存分配时,将抛出OOM异常。

1.6运行时常量

运行时常量是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

运行期间也可能将新的常量放人池中,比如String类的intern()方法。

2. 对象访问

Object obj = new Object();

Object obj 会被反映到Java栈的本地变量表中,作为一个引用类型出现;而 **new Object() ** 这部分会反映到堆中,形成一块储存了Object类型所有实例数据值的结构化内存。另外,在Java堆中还必须包括能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则是储存在方法区中。

3. 垃圾回收器

3.1 引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器就减1;当计数器为0时的对象表示可以回收。

程序计数器无法解决对象之间的互相循环引用问题。

3.2 跟搜索算法

Java使用的是跟搜索算法,这个算法的思路是通过一系列名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,证明这个对象是不可用的。

java语言中可作为GC Roots的对象包括下面几种:

  1. 在虚拟机栈中的引用的对象
  2. 方法区中的静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI的引用的对象

3.3 二次标记

当跟搜索算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经过两次标记过程,第一次是如果对象在进行跟搜索后发现并没有GC Roots 相连,该对象会被第一次标记且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,当这个对象没有覆盖重写finalize()方法,或者该方法已经被执行过,此时认为该对象"没有必要执行",会进行第二次标记。

如果被判定为有必要执行finalize()方法,那么这个对象将会放置在一个名为F-Queue的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行。

如果载finalize()方法中成功拯救自己,即重新与引用链上的任何一个对象关联,那么这个对象将会被移除第一次标记的集合中。

finnalize()方法只会被执行一次,如果对象面临下一次回收,它的finzlize()方法不会被再次执行。

4. 垃圾回收算法

4.1 标记-清除 算法

最基础的算法是 标记-清除 算法,算法分为两个阶段:首先标记出需要回收的对象,在标记完成后统一回收所有被标记的对象。

缺点:会产生大量的空间内存碎片,空间内存碎片太多可能会导致大对象无法分配到合适的内存空间。

4.2 复制 算法

为了解决标记-清楚 算法引起的内存碎片过多问题,复制 算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内容用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中一块进行内存回收。

JVM虚拟机采用的 复制 算法来回收新生代区域,将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中的一块 Survivor 。当回收时,将 Eden 和 Survivor 中还存活的对象一次性地拷贝到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 的空间。

缺点:当对象存活率较高的时候需要进行多次复制操作,效率将会变低。

HotSpot 虚拟机默认 Eden 和两个 Survivor 的大小比例是 8 : 1 : 1 ,也就是有 10% 的内存是一定会被浪费的。

4.3 标记-整理 算法

为了解决复制 算法在对象存活率高引起的效率低问题,标记-整理 算法出现了,标记的过程仍然与标记-清除 算法一致,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

4.4 分代收集 算法

根据对象的存活周期的不同将内存划分为几块,在不同的区域采用最适合的收集算法。

5. 内存分配和回收策略

5.1 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC (轻 GC )。

新生代 GC (Minor GC) :指发生在新生代的垃圾收集动作,因为 Java 对象大多数都具备朝生熄灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。

老年代 GC (Major GC / Full GC):指发生在老年代的 GC ,出现了 Major GC ,经常会伴随至少一次 Minor GC。Full GC 的速度一般会比 Minor GC 慢十倍以上。

5.2 大对象直接进入老年代

所谓大对象就是指,需要大量连续内存空间的 Java 对象,在大对象生成时会直接放置在老年区,避免在 Eden 区及两个 Survivor 区之间发生大量的内存拷贝。

5.3 长期存活的对象将进入老年代

虚拟机既然采用了分代收集的思想来管理内存,那内存回收时就必须能识别哪些对象应当放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 出生并且经过一次 Minor GC后仍然存活,并且能够被 Survivor 区容纳的话,将被移动到 Survivor 空间中,并将对象的年龄 + 1 ,对象在 Survivor 区中没经过一次 Minor GC 且不被回收的话,年龄就增加 1 岁,当年龄增加到一定程度 (默认是 15 岁)时,就会被晋升到老年代中。

5.4 动态对象年龄判定

如果载 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

6. 垃圾收集器

6.1 CMS 收集器

CMS 收集器是一种获取最短回收停顿时间为目标的收集器,重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

CMS 收集器是基于标记-清除 算法实现的,运作过程分为四个步骤:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除

6.2 G1 收集器

G1 收集器是基于标记-整理算法实现的收集器,不会产生垃圾碎片,对于长时间运行的应用系统来说非常重要。同时可以非常精准地控制停顿,既能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上时间不超过N毫秒。

G1 将整个 Java 堆划分为多个大小固定的独立区域,并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的时间,优先回收垃圾最多的区域。

二、字节码与类的加载篇

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用、和卸载七个阶段。其中 验证、准备、和解析三个部分被统称为连接。

1. 类加载的过程

1.1 加载

加载阶段是“类加载”过程的一个阶段,虚拟机需要完成以下三件事:

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

加载阶段既可以使用系统提供的类加载器完成加载,也可以由用户自定义类加载器完成。

加载阶段完成后,在虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,然后在 Java 堆中实例化一个 java.lang.Calss 类的对象,这个对象作为程序访问方法区中的这些类型数据的外部接口。

1.2 验证

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

1.3 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。首先这时候进行内存分配的仅包括类变量即 static 修饰的变量,而不包括实例变量,实例对象将在对象实例化的时候随着对象一起分配在 Java 堆中;其次是这里所说的初始值是数据类型的零值,假设一个类变量定义为:public static int value = 123;

那么该变量 value 在准备的时候初始值会被赋予 0 ,而不是 123 ,这是因为这个时候尚未开始执行任何 Java 方法。

1.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程

1.5 初始化

类初始化阶段是类加载的最后一步,初始化阶段才真正执行类中定义的 Java 程序代码。

在准备阶段,变量已经赋值过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源。

2. 双亲委派模型

三种系统类加载器:

  1. 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将存放在 < JAVA_HOME > \ lib 目录中的类。
  2. 扩展类加载器(Extension ClassLoader):这个加载器负责加载 < JAVA_HOME > \ lib \ ext 目录中的类。
  3. 应用程序类加载器(Application ClassLoader):这个类加载器负责加载用户类路径 ClassPath 上所制定的类库,一般情况下这个就是程序中默认的类加载器。

双亲委派的工作过程:

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

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。双亲委派模型对于保证 Java 程序的稳定运作很重要,实现过程:先检查是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,则在抛出 ClassNotFoundException 异常后,再调用自己的 findClass() 方法进行加载。

三、高效并发

1. Java内存模型与线程

1.1 主内存与工作内存

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的的底层细节。

Java 内存模型规定了所有的变量都存储在主内存 (Main Memory)中。每个线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节, Java 内存模型中定义了一下八种操作来完成:

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

1.2 对于 volatitle 型变量的特殊规则

关键字 volatitle 可以说是 Java 虚拟机提供的最轻量级的同步机制,当一个变量被定义成 volatitle 之后,它将具备两种特性:

  1. 保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,变量值在线程间传递均需要通过主内存来完成。
  2. 禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

2. 原子性、可见性与有序性

2.1 原子性

原子性:基本数据类型的访问读写是具备原子性的。

2.2 可见性

可见性:可见性就是指一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

2.3 有序性

有序性

3. Java 与 线程

3.1 线程的实现

主流的操作系统都提供了线程实现,Java 语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个 Java.lang.Thread 类的实例就代表了一个线程。不过 Thread 类与大部分的 Java API 有着显著的差别,它的所有关键方法都被声明为 Native。

3.2 线程的状态

新建(New):创建出来尚未启动的线程处于这种状态。
运行(Runnable):Runnable 包括了操作系统线程状态中的 Running 和 Ready,也就是处于此状态的线程有可能正在等待 CPU 为它分配执行时间。
无限期等待(Waiting):处于这种状态的进程不会被分配 CPU 执行时间,它们要等待被其他线程显式地唤醒。例如:Object.wait() 和 Thread.join() 和 LockSupport.park()
限期等待(Timed Waitting):处于这种状态的进程也不会被分配 CPU 执行时间,不过无须等待被其他线程显式地唤醒,在一定时间之后它们由系统自动唤醒。
阻塞(Blocked):等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生。
结束(Terminated):已终止线程的线程状态,线程已经结束执行。

3.3 线程的类型

  1. 使用内核线程实现

内核线程就是直接由操作系统内核支持的线程,这种线程由内核来完成线程的切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程都可以看作是内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫多线程内核。

  1. 使用用户线程实现

广义上来说,一个线程只要不是内核线程,那就可以认为是用户线程,用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到线程的存在与毁灭。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作系统可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。

使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑问题,而且由于操作系统只把处理器资源分配到进程,需要用户解决线程的“阻塞、同步等问题”。

4. 线程安全与锁优化

4.1 线程安全

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

4.2 线程安全的实现方法

  1. 互斥同步

是最常见的一种并发正确性保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只能被一条线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。

在 Java 里面,最基本的互斥同步手段就是 synchronize 关键字,synchronize 关键字经过编译之后,会在同步块的前后分别形成 monitor.enter 和 monitor.exit 这两个字节码指令,这两个字节码指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象;如果没有指定,那就根据 synchronize 修饰的是实例方法还是类方法,去取对应的对象实例或 Class 对象来作为锁对象。

根据虚拟机规范的要求,在执行 monitor.enter 指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加 1 ,相应地,在执行 monitor.exit 指令时会将锁计数器减 1 ,当计数器为 0 时,锁就释放了。如果对象获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

在虚拟机规范对 monitor.enter 和 monitor.exit 的行为描述中,有两点需要特别注意的。首先, synchronize 同步块对同一个线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。

除了 synchronize 之外,我们还可以使用 java.util.concurrent(J.U.C)包中的重入锁(ReentrantLock)来实现同步,在基本用法上,ReentrantLock 与 synchronize 很相似,他们都具备一样的线程重入特性,只是代码写法上有点区别,一个表现为 API 层面的互斥锁,一个表现为原生语法层面的互斥锁。不过 ReentrantLock 增加了一些高级功能,主要以下三项:

等待可中断:指当前持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情;

公平锁:指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁; synchronize 中的锁时非公平的,ReentrantLock 默认情况下也是非公平的,但是可以通过带布尔值的构造函数要求公平锁;

锁绑定多个条件:指一个 ReentrantLock 对象可以同时绑定多个 Condition 对象,而在 synchronize 中,锁对象的 wait() 和 notify() 或 notifyAll() 方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而 ReentrantLock 则无须这样做,只需要多次调用 newCondition() 方法即可。

  1. 非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也被称为阻塞同步。另外,它属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(加锁)那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心态的转换、维护锁计数器和检测是否有被阻塞的线程需要被唤醒等操作。

比较与交换(CAS):需要三个操作数,分别是 内存位置(变量的内存地址,用 V 表示)、旧的预期值( A 表示)和新值( B 表示)。CAS 指令执行时,当且仅当 V 符合旧预期值 A 时,处理器用新值 B 更新 V 的值,否则会更新旧值 A ,并重新进行比较与交换(自旋)直到成功。

存在 ABA 问题:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查打它仍为 A 值,那我们就能说它的值没有被其他线程改变过了吗?如果在这段时间内它的值曾经被改成了 B ,后来又改回了 A ,那 CAS 操作就会误认为它从来没有被改变过。

  1. 线程本地储存

线程本地储存:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之前不出现数据争用的问题。

Java 语言中,如果一个变量要被多线程访问,可以使用 volatitle 关键字声明它为“易变的”;如果一个变量要被某个线程独享,可以通过 java.lang.ThreadLocal 类来实现线程本地储存的功能。每一个线程的 Thread 对象中都有一个 ThreadLoaclMap 对象,这个对象储存了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值 的 K-V 键值对,ThreadLocal 对象就是当前线程的 ThreadLocalMap 的访问入口,每一个 ThreadLocal 对象都包含了一个独一无二的 threadLocalHashCode 值,使用这个值就可以在线程 K - V 值对中找回对应的本地线程变量。

4.3 锁优化

  1. 自旋锁和自适应自旋

前面我们讨论互斥同步的时候,提到了互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会维持很短的一段时间,为了这段直接去挂起和恢复线程并不值得。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

自旋等待不能代替阻塞,自旋等待本身虽然避免线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过一定次数或者时间,就应当使用传统的方式去挂起线程了。

自适应自旋锁:自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也有可能再次成功,进而它将允许自旋等待的持续相对更长的时间。另一方面,对于某个锁,自旋很少成功获得过,那在以后要获得这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

  1. 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然无须进行。

比如 String 对象是不可变的,在进行 s1 + s2 + s3 时内部会转化为 StringBuffer (JDK1.5之前)进行 append 操作,StringBuffer的所有方法都有 synchronize 同步块,但是虚拟机会进行锁消除。

  1. 锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小—只在共享数据的实际作用域中才进行同步,这样使为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快地拿到锁。

但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能消耗。

比如在购物中,只在扣除货存这一步加上同步块,不必要在登录购物车这一步就开始加入同步块。

  1. 轻量级锁

轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

简单地介绍完了对象的内存布局,我们把话题返回到轻量级锁的执行过程上。在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录 (Lock Record) 的空间,用于存储锁对象目前的 Mark word 的拷贝(官方把这份拷贝加了一个 Displaced前缀,即 Displacedark word ),这时候线程堆栈与对象头的状态如图13-3所示。

然后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock record 的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位 (Mark word的最后两个Bits ) 将转变为 “00” ,即表示此对象处于轻量级锁定的状态。

如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”, Mark Word 中存储的就是指向重量级锁 (互斥量) 的指针,后面等待锁的线程也要进入阻塞状态。

上面描述的是轻量级锁的加锁过程,它的解锁过程也是通过CAS操作来进行的,如果对象的 Mark Word 仍然指向着线程的锁记录,那就用CAS操作把对象当前的 Mark word 和线程中复制的 Displaced Mark Word 替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同醒被挂起的线程。

  1. 偏向锁

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

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

当另一个线程尝试获取这个锁时,偏向模式的宣告结束。根据锁对象目前是否处于被锁定(01)的状态,撤销偏向后恢复到未锁定或轻量级(00)锁定状态。

偏向锁可以提高带有同步但无竞争的程序性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值