Parler de la JVM en entretien pendant 20 minutes

Parcourir la mémoire JVM, la structure des objets et le GC, puis examiner les journaux GC et les thread dumps.

基础知识

定义

JVM 是运行 Java 字节码的引擎,负责管理内存、加载类、执行字节码和提供垃圾回收等服务。

JVM结构

  1. 类加载子系统:负责将 .class 文件加载到 JVM 内存中,并解析、验证、准备和初始化类。具体过程:
    • a) 查找并导入类的二进制数据。
    • b) 确保导入的类文件符合 JVM 规范,没有安全性问题。
    • c) 为类的静态变量分配内存,并将其初始化为默认值。
    • d) 将常量池中的符号引用转换为直接引用。
    • e) 执行类的静态初始化块和静态变量的赋值操作。
  2. 运行时数据区:JVM 在运行时所需的内存区域,包括方法区、堆、Java 虚拟机栈、本地方法栈和程序计数器。各区域的内容:
    • 方法区:存储已加载类的结构信息、常量池、方法数据和方法代码等。方法区是线程共享的。
    • 堆:存储所有的对象实例和数组。堆是线程共享的,并且是垃圾回收的主要区域。
    • Java 虚拟机栈:每个线程都有一个 JVM 栈,存储局部变量、操作数栈、动态链接和方法出口等。每个方法调用对应一个栈帧。
    • 本地方法栈:存储本地方法的调用信息。
    • 程序计数器:每个线程都有一个程序计数器,指示当前正在执行的字节码指令的地址。
  3. 执行引擎:负责执行字节码,包括解释器、即时编译器JIT和垃圾回收器GC。

Java中的内存区域

  1. 方法区:存储已加载类的结构信息、常量池、方法数据和方法代码等。方法区是线程共享的。方法区在 Java 8 之前被称为“永久代”,Java 8 之后被称为“元空间”。
  2. 堆(Heap):存储所有的对象实例和数组。堆是线程共享的,并且是垃圾回收的主要区域。堆内存被进一步划分为新生代和老年代。
  3. 栈(Stack):每个线程都有一个栈,存储局部变量、操作数栈、动态链接和方法出口等。每个方法调用对应一个栈帧。栈是线程私有的,每个线程都有自己的栈空间。栈内存管理采用的是栈式管理方式,先进后出。
  4. 本地方法栈:存储本地方法的调用信息。栈是线程私有的,与 Java 栈类似,只不过它为 Native 方法服务。
  5. 程序计数器:每个线程都有一个程序计数器,指示当前正在执行的字节码指令的地址。
  6. 运行时常量池:存放编译期生成的各种字面量和符号引用。运行时常量池是方法区的一部分。
  7. 直接内存:在进行 I/O 操作时,直接内存可以显著提高性能,因为它避免了在 Java 堆和操作系统之间的数据复制。通过 java.nio 包中的 ByteBuffer 类使用。

JVM结构和内存区域的关系

  1. 类加载器子系统主要在方法区,类加载后,类的元数据和静态变量存储在方法区。
  2. 运行时数据区在所有内存区域。
  3. 执行引擎不直接管理特定的内存区域,但与所有内存区域交互,用于执行和优化字节码,管理对象的生命周期和垃圾收集。

HotSpot虚拟机(对象)

定义

HotSpot虚拟机是Oracle JDK中的默认JVM的实现。它负责运行Java程序,通过将Java字节码转换为机器码来执行代码。对象的创建、管理和销毁是核心功能之一。理解HotSpot虚拟机如何处理对象有助于优化内存使用和提高应用性能。

对象在内存中的布局

对象在内存中的布局是指对象在堆内存中的存储结构。最重要的就是对象头中的标记字,记录的内容高度压缩且多功能,其中GC标记被GC用于确认对象存活状态,哈希码是对象的唯一标识,锁状态用于锁升级降级等记录。

  • 对象头:
    1. 标记字:存储对象的状态信息,包括对象的哈希码、GC标记、锁信息等。
      • GC标记:GC在扫描对象时,会使用标记字中的GC标记信息来确定对象的存活状态。
      • 哈希码:用于标识对象的唯一性,通常用于哈希表数据结构中(如HashMap、HashSet),对象的哈希码是由Object类的hashCode()方法生成的。hashCode()通常是基于对象的内存地址计算的,因为对象的内存地址在 JVM 内部是唯一的,并且可以用来生成一个相对独特的哈希码。
      • 锁信息:用于实现同步和锁机制。表示对象的锁状态:
        • 无锁状态:对象没有被任何线程锁定,标记字中的锁信息部分为空或标记为无锁状态。
        • 偏向锁:包含线程 ID以标识当前锁的持有者,偏向于第一个获得锁的线程来提高性能。
        • 轻量级锁:当多个线程竞争同一个偏向锁时,JVM 将偏向锁升级为轻量级锁。标记字会包含一个指向轻量级锁的对象(互斥量)的指针。轻量级锁使用自旋锁来尝试获取锁,减少了对操作系统的上下文切换。
        • 重量级锁:如果多个线程竞争轻量级锁失败,锁会升级为重量级锁。重量级锁使用操作系统的互斥量机制来实现同步。
      • 标记字:会包含指向操作系统互斥量的指针,锁的持有者会被标记为等待线程的 ID(正在等待某个锁释放的线程的标识符)。
    2. 类型指针:指向对象所属的类的定义,用于支持对象的动态类型检查和方法调用。
    3. 数组长度:专用于数组对象,存储数组的长度。
  • 实例数据
  • 对齐填充

对象锁和类锁

对象锁是Java中最基本的锁机制,也称为监视器锁。每个对象都有一个关联的对象监视器,用于控制对该对象的同步访问。对象锁 和 偏向锁 是 JVM 中两种不同的锁机制。

  1. 对象锁(细粒度):每个Java对象都有一个关联的对象锁。对象锁用于控制对象实例的变量和方法的访问,以确保同一时间只有一个线程可以执行被该对象锁保护的代码块。通过synchronized关键字来实现:synchronized(this)
  2. 类锁(粗粒度):每个类在JVM中都有一个唯一的Class对象,该对象用于表示类的元数据。类锁用于控制对类级别的资源的访问,例如静态字段和静态方法。通过synchronized关键字来实现:synchronized(Example.class)

对象的引用

是指向对象实例的一个变量,用于访问和操作对象。引用实际上是指向内存中对象实例的一个句柄或地址。 对象引用在内存中的具体表示方式依赖于JVM的实现。以下是几种常见的表示方式:

  1. 直接内存地址:引用直接表示为对象在内存中的地址。这种方式简单直接,但在64位系统上可能会面临地址空间的限制。
  2. 偏移量:在64位JVM中为了节省内存,存储一个相对于某个基准地址的偏移量。基准地址可以是虚拟地址中的某个起始点。
  3. 句柄(Hqndler):某些JVM实现使用句柄表来管理对象引用。每个对象引用指向一个句柄,而句柄包含了对象的实际内存地址和其他元数据。这种方式使得对象的内存位置可以在运行时改变,而引用仍然有效。

对象的生命周期

对象的生命周期指的是从对象被创建到被销毁的整个过程。

  1. 对象创建:包含有内存分配和引用分配,内存分配:当你使用new关键字创建一个对象时,JVM会在堆内存中分配足够的内存来存储对象的数据和对象头。引用分配:创建的对象会被一个或多个引用变量引用,引用变量用于访问对象的字段和方法。
  2. 对象使用:在对象的生命周期内,可以通过对象的引用访问和操作对象的字段和方法。
  3. 对象销毁:当对象不再被任何引用变量引用时,它会变成垃圾。JVM的垃圾回收器会自动识别并回收这些对象。

对象的分代

分代假设是垃圾回收设计中的一个基本原则,它基于观察到的对象生命周期规律。该假设主要包括两个核心观点:大多数对象都是短命的(创建后很快变得不再使用)、长期存活的对象比短期存活的对象更少。 </br> HotSpot虚拟机将堆内存分为以下几个代:

  1. 年轻代:专门用于存储新创建的对象。
  2. 老年代:用于存储那些在年轻代经过多次垃圾回收仍然存活的对象。
  3. 元空间:存储类的元数据(如类的结构、方法信息等)。元空间的回收通常是针对类的卸载和类的元数据的清理。元空间的垃圾回收机制可能不完全相同于堆的垃圾回收机制。

对象的深度复制

  • 深度复制:复制对象及其所引用的所有对象,从根对象开始,通过递归的方式完成。
  • 浅复制:只复制对象本身和其引用,而不复制引用的对象,这会导致新旧对象共享引用。 </br>

深度复制的实现方法:

  1. 使用序列化:通过将对象序列化为字节流。再将其读回,从字节流中反序列化出新的对象。
  2. 手动实现深度复制:需要编写自定义的复制方法,递归地复制对象及其所有子对象。
  3. 使用构造函数:通过定义一个构造函数,可以创建一个新对象并复制原对象的状态。

对象的序列化

序列化是将对象转换为字节流的过程,序列化后的字节流可以被反序列化,即将字节流恢复为原始对象。在分布式系统和持久化存储中广泛使用。 </br>

对象序列化:

  1. 实现Serializable接口:这个接口是一个标记接口,不需要实现任何方法。
  2. 使用ObjectOutputStream进行序列化:使用ObjectOutputStream类将对象写入字节流中。

对象的反序列化:

  1. 使用ObjectInputStream进行反序列化

序列化和反序列化的注意事项:

  • serialVersionUID是序列化版本的唯一标识符,用于确保反序列化时的兼容性。如果类的定义发生变化(例如添加了新的字段),serialVersionUID可以帮助检测反序列化中的不兼容问题。
  • transient,标记为transient的字段不会被序列化。适用于那些不需要持久化的字段,或者是临时计算的数据。
  • 在反序列化时,如果类的结构发生了变化,可能会导致InvalidClassException异常。
  • 反序列化可能会带来安全风险,如反序列化攻击。在接受不可信的序列化数据时,需要采取额外的安全措施。

【未完成】同步原语

垃圾收集器GC

是编程语言运行环境(如JVM)中的一个重要组件,负责自动管理和回收不再被应用程序使用的内存。它通过识别不再使用的对象并释放其占用的内存,防止内存泄漏和优化内存使用。

引用类型

引用类型决定了对象的可达性和垃圾收集器的行为

  • 强引用(New):最强的引用类型,只要强引用存在,垃圾回收器永远不会回收被引用的对象。
  • 软引用(SoftReference):内存不足时会回收被软引用的对象,适用于缓存实现。
  • 弱引用(WeakReference):不论内存是否充足,垃圾回收器都会回收只被弱引用的对象,常用于内存敏感的缓存。
  • 虚引用(PhantomReference):最弱的引用类型,无法通过引用访问对象,仅用于跟踪对象回收状态。

对象的存活周期

对象的存活周期取决于其引用是否存在。

  • 创建阶段:对象被创建并分配内存空间。
  • 引用阶段:对象被引用,可以通过引用访问到。
  • 活动阶段:对象在程序中被使用,处于活跃状态。
  • 不可达阶段:对象不再被任何活动对象引用,成为不可达对象。
  • 垃圾收集阶段:垃圾收集器检测到不可达对象,并回收其占用的内存空间。

算法

  1. 标记-清除算法:标记所有能被访问的对象,清除其他对象并回收其内存。 </br> 特点:清除阶段会造成停顿,影响程序的响应时间和性能。

  2. 复制算法:用于年轻代,将堆内存分为两块区域:伊甸区、幸存者区(通常分为Survivor 0和Survivor 1)。 </br> 在任何时候,只有一个半区是使用中的,另一个是空闲的。当进行垃圾收集时,将存活的对象从使用中的半区复制到空闲的半区,清空使用中的半区。接着,交换这两个半区的角色。多次复制后仍然存活的对象会被移到老年代。 </br> 特点:减少了内存碎片的产生。适用于新生代对象的高回收率场景。

  3. 标记-整理算法:通常用于老年代,类似于标记-清除算法,但是在清除阶段,不是简单地清除未标记的对象,而是将存活对象压缩到堆的一端,然后直接清理掉边界外的内存。 </br> 特点:减少了内存碎片,但是需要额外的移动操作。清理阶段会造成停顿,影响程序的响应时间和性能。

  4. 分代算法:根据对象的存活周期将堆内存划分为不同的代,通常是新生代和老年代。大部分对象的生命周期很短,所以新生代使用复制算法,老年代使用标记-清除或标记-整理算法。 </br> 特点:充分利用不同对象的生命周期,优化了垃圾回收的效率。HotSpot VM 默认使用的是分代算法,因为大多数对象在其生命周期中都会很快变为不可达状态。

  5. 并行和并发算法

    • 并行算法:指多个垃圾回收线程并行执行垃圾回收操作,提高回收效率。如并行复制、并行标记-清除等。
    • 并发算法:指在应用程序执行的同时,垃圾回收线程也在运行,减少停顿时间。如CMS算法、G1算法等。

垃圾收集器的种类

  1. 串行GC (Serial GC):单线程垃圾收集器,适用于单核或低核心数的系统。在进行垃圾收集时,暂停所有应用线程。使用-XX:+UseSerialGC参数启用。
  2. 并行GC (Parallel GC):多线程垃圾收集器,适用于多核系统和大堆应用场景。在进行年轻代的垃圾收集时,暂停所有应用线程。使用-XX:+UseParallelGC参数启用。优化:-XX:+UseParallelOldGC:启用并行老年代垃圾收集(在UseParallelGC的基础上)。
  3. 并行旧版GC (Parallel Old GC):针对老年代进行并行垃圾收集,提升对老年代垃圾收集的效率。适用于需要高吞吐量的应用。与Parallel GC类似,但额外支持并行收集老年代。通常与-XX:+UseParallelGC一起使用,自动启用。
  4. 并发标记清除GC (CMS GC, Concurrent Mark-Sweep GC):通过并发标记和清除阶段减少了垃圾回收的停顿时间。主要用于减少应用线程的暂停时间 (即低延迟需求)。它也存在内存碎片化、高内存消耗和终结器处理问题。通过使用G1垃圾回收器、增加堆内存、调整CMS参数以及避免使用终结器,可以有效地规避CMS垃圾回收器的缺点JDK 9及以后版本,CMS已被标记为弃用。使用-XX:+UseConcMarkSweepGC参数启用。
  5. G1 GC (Garbage-First GC):特别适用于大内存应用。通过区域化管理、可预测的停顿时间、混合回收策略和并发执行等特点,弥补了CMS的一些缺点。相比CMS,G1在减少内存碎片化、提高回收效率、支持大堆内存和提供可预测的停顿时间方面具有明显优势。使用-XX:+UseG1GC参数启用。
  6. ZGC (Z Garbage Collector):适用于需要非常低停顿的应用,如大规模数据处理和实时计算。 </br> 核心特性是 零停顿时间(Pause-less):
    • 并发垃圾收集:标记阶段在应用线程运行的同时进行,在垃圾收集期间并发地进行对象的重定位,即将存活的对象移动到新的位置。
    • 区域化内存管理:将内存划分为多个固定大小的区域(这样可针对特定区域进行操作,减少了对整个堆的扫描)。
    • 协作式同步:在执行垃圾收集时,ZGC 通过使用安全点来进行线程的同步。安全点是应用线程在执行时允许的停顿点,垃圾收集器会在这些点上进行必要的操作,以便线程可以安全地进行垃圾收集。通过设计轻量级的同步机制来减少同步开销,避免了传统锁机制带来的大量停顿时间。
    • 并发整理:并发地整理内存,将存活的对象移动到新的区域。通过使用高效的数据结构和算法来保证垃圾收集过程的快速和高效。
    • 指针压缩和元数据管理:指针压缩技术来减少内存占用和提高访问速度。对对象的元数据进行专门管理,以确保在垃圾收集期间可以快速地访问和更新对象的信息。 </br> 使用-XX:+UseZGC参数启用 (JDK 11及以后版本)。
  7. Shenandoah GC:低延迟垃圾收集器,与ZGC类似,但也支持更多的应用场景。实现了低延迟垃圾收集,减少停顿时间,尤其在老年代的回收上。使用-XX:+UseShenandoahGC参数启用 (JDK 12及以后版本)。

备注: </br> Minor GC:新生代的垃圾收集称为Minor GC。 </br> Full GC:老年代的垃圾收集称为Full GC。

GC回收停顿时间

是指在进行垃圾收集时应用程序线程暂停的时间。在垃圾收集期间,JVM需要暂停应用程序的所有线程,以安全地进行垃圾回收操作。这种暂停时间会影响应用程序的响应性和吞吐量,因此管理和优化回收停顿时间是垃圾收集器设计中的关键目标。

GC停顿类型: </br> Minor GC停顿时间:新生代垃圾收集时,通常较短,但频繁发生,可能会影响应用程序的响应时间。 </br> Full GC停顿时间:涉及整个堆(新生代和老年代)的垃圾收集,通常发生在老年代回收时。较长,影响更大。

控制GC回收停顿时间的策略包括:

  1. 使用多线程来并行处理垃圾收集任务(如并行GC、G1 GC、ZGC、Shenandoah GC)。
  2. 在并发标记和清除阶段减少应用线程的暂停(如CMS GC)。
  3. 按需回收并优化区域化堆内存(如G1 GC)。通过优先回收含有最多垃圾的区域来达到这个目标。
  4. 设定期望的最大停顿时间(如-XX:MaxGCPauseMillis)来指导垃圾收集器进行优化(如G1 GC)。

增量式垃圾收集

是一种垃圾收集策略,其主要目的是通过将垃圾收集工作分解成多个小的、较短的增量步骤,来减少单次垃圾收集的停顿时间。这种方法可以在不完全暂停应用程序的情况下逐步进行垃圾收集,从而减少对应用程序响应性的影响。

  • CMS垃圾收集器:通过并发标记和清除减少停顿时间。
  • G1垃圾收集器:通过按需回收区域化堆内存来减少停顿时间。优先回收垃圾最多的区域。
  • ZGC和Shenandoah GC:通过几乎完全并发的垃圾收集操作实现低停顿时间。

大对象分配

大对象通常会被直接分配到老年代或大对象区域,因为它们可能无法容纳在新生代中。 处理策略:

  1. 串行和并行GC:将大对象分配到老年代,可能导致较长的停顿时间。
  2. CMS GC:通过并发标记和清除来减少停顿时间,但可能会遇到空间碎片化问题。
  3. G1 GC:通过区域化管理和回收策略来优化大对象的处理,并允许设置最大停顿时间。
  4. ZGC和Shenandoah GC:通过几乎完全并发的垃圾收集操作和分区管理来处理大对象,减少停顿时间。

垃圾收集器的分析和优化

GC日志

  • GC日志生成:通过在JVM启动参数中配置GC日志选项,记录垃圾回收的详细信息。
  • GC日志分析:使用工具如jstat、GCViewer、GCEasy或VisualVM来分析GC日志,查看GC事件的停顿时间、频率、内存使用情况等。
  • GC性能分析:关注GC停顿时间、GC频率、内存使用情况以及年轻代和老年代的GC行为,以优化垃圾回收性能。

GC性能分析步骤:

  1. GC停顿时间:检查GC事件的停顿时间和总停顿时间。如果停顿时间过长或不稳定,可能需要调整垃圾回收器或内存配置。
  2. GC频率:分析GC事件的频率,检查GC是否过于频繁。如果GC频率过高,可能需要调整堆内存大小或优化代码。
  3. 内存使用情况:分析GC前后的内存使用情况,检查是否有内存泄漏或内存不足的问题。
  4. 年轻代和老年代GC:分析新生代GC和老年代GC的行为,检查是否需要调整新生代和老年代的比例或垃圾回收策略。

逃逸分析

是一种在编译器中进行的优化技术,用于确定对象的作用域和生命周期,以便优化内存分配和垃圾回收。 </br> 逃逸分析的主要目标是识别和优化那些不需要逃逸到方法外部的对象,从而减少不必要的内存分配和提高程序性能。

  1. 逃逸分析的概念
    • 逃逸:对象的逃逸指的是对象的引用超出了其创建方法的作用域,即对象的引用可能被方法外的代码访问。例如,一个对象可能被返回给调用者,或者被保存到类的字段中。
    • 非逃逸:对象的引用完全被限制在创建它的方法或线程中,即对象无法被方法外的代码访问。
  2. 逃逸分析的优化策略
    • 栈上分配:非逃逸对象的实例分配在栈上而不是堆上。栈上分配的对象在方法调用结束后自动释放,减少垃圾回收的负担。
    • 消除锁:确定一个对象在多线程环境中不被共享,则可以消除对该对象的锁操作,从而减少锁竞争和提高并发性能。
    • 标量替换:复杂对象(如包含多个字段的对象),将其拆分为多个基本类型字段,从而避免对象创建和内存分配的开销。

JVM性能监控与故障

内存泄漏

内存泄漏指的是程序在运行过程中,由于错误的内存管理导致的内存资源无法被正常释放和回收的情况。这种情况下,被分配的内存无法再次被应用程序使用,最终导致系统可用内存减少,甚至引发内存溢出错误。

内存泄漏原因分析

常见原因包括:

  1. 对象引用未释放:在使用完对象后,未将其引用置为 null,导致对象仍然被引用,无法被垃圾收集器回收。
  2. 集合类对象:如 ArrayList、HashMap 等,如果没有适时地清理或移除不再需要的元素,可能会导致集合持有的对象无法被释放。
  3. 资源未关闭:如文件、数据库连接、网络连接等,如果在使用完后没有正确关闭,会导致资源泄漏。
  4. 循环引用:两个或多个对象之间相互引用,且这些引用形成一个闭环,即使这些对象不再被其他对象引用,也无法被垃圾收集器回收。

如何避免内存泄漏:

  1. 及时释放不再使用的对象引用、使用弱引用或者软引用来管理可能导致内存泄漏的对象,允许垃圾收集器根据需要回收这些对象。
  2. 使用 try-with-resources:对于需要手动关闭的资源(如文件、数据库连接等),使用 Java 7 引入的 try-with-resources 语句可以确保资源在使用完毕后自动关闭,避免忘记关闭资源而导致的泄漏。
  3. 时不时使用监控工具、定期代码审查、测试、内存分析工具等及时发现和修复内存泄漏问题。

内存泄漏使用工具

内存泄漏是指程序中分配的内存无法被回收,导致内存资源不断增加,从而影响应用程序的性能和稳定性。

  1. 找出内存泄漏源头 我主要使用JVisualVM中的“Heap Dump”分析工具,查看内存使用情况,识别高占用的对象和类。
    1. 关注指标:
      • 对象保留树:查看哪些对象占用了大量内存。
      • 类实例统计:查看哪些类的实例数量异常。
      • 对象引用链:检查无法回收的对象及其引用链,找出潜在的内存泄漏源。
  2. 修复内存泄漏方法
    1. 代码审查和修改
      • 检查对象引用:确保不再使用的对象可以被垃圾回收。移除对不再需要的对象的引用。
      • 关闭资源:确保使用完的资源(如数据库连接、文件流)被正确关闭。
      • 优化集合使用:检查集合(如List、Map)的使用,避免不必要的对象保留。
      • 避免静态引用:减少对静态字段的引用,因为它们会在整个应用程序生命周期内存在。
    2. 使用更高效的数据结构
      • 选择合适的数据结构:使用合适的集合类(如WeakHashMap)来避免不必要的内存占用。
      • 减少数据结构的复杂度:优化数据结构,减少内存占用。
    3. 调整JVM参数
      • 调整堆大小:增加堆内存的大小,减少内存溢出的风险。
      • 配置GC策略:根据应用程序的特点选择合适的GC策略,以优化内存回收。 </br> 示例:调整堆大小:-Xms512m -Xmx4g </br> 示例:使用G1垃圾收集器:-XX:+UseG1GC

死锁

  1. 死锁问题的检测
    1. 使用线程转储(Thread Dump)分析
      • 如何生成线程转储:JVisualVM:在“Threads”选项卡中点击“Thread Dump”按钮生成线程转储。
      • 如何分析线程转储: 查找线程状态:查找状态为BLOCKED的线程,通常这些线程处于等待锁的状态。 识别死锁:有Deadlock标识,显示涉及线程和锁的详细信息。识别线程之间的锁依赖关系。
  2. 死锁问题的解决
    1. 修改代码以避免死锁 </br> 死锁避免策略:
      • 避免嵌套锁:尽量避免在持有一个锁时再去获取另一个锁。
      • 锁的顺序:确保所有线程以相同的顺序获取锁,这样可以减少死锁的可能性。
      • 锁的超时:尝试获取锁的方式(如tryLock),并设置锁的超时,以避免长时间等待锁。
      • 减少锁的范围:尽量缩小锁的范围和持有时间,避免在持有锁时执行耗时操作。
  3. 优化锁策略
    • 使用更高层次的锁机制:考虑使用java.util.concurrent包中的并发工具类(如ReentrantLock、Semaphore、CountDownLatch),这些工具提供了更灵活的锁机制和避免死锁的功能。
    • 设计模式:使用设计模式(如生产者-消费者模式、读写锁模式)来减少锁的使用和复杂度。

【未整理】Java内存模型

  1. 主内存和工作内存:
    • 主内存:所有线程共享的内存区域,包含所有的共享变量。
    • 工作内存:每个线程私有的内存区域,存储了该线程使用到的变量的副本。线程对变量的操作必须在工作内存中进行,不直接操作主内存。
  2. 内存间交互操作:Java 内存模型定义了一组规则,确保线程在执行读取、写入共享变量操作时,能够正确地进行内存间的交互,包括:
    • 原子性(Atomicity):确保某些操作是不可分割的,要么全部执行成功,要么全部不执行。
    • 可见性(Visibility):一个线程对共享变量的修改对其他线程是可见的。
    • 顺序性(Ordering):指令重排序可能导致的影响,Java 内存模型通过 happens-before 原则定义了执行顺序。
  3. 同步机制:Java 提供了多种同步机制来帮助程序员编写线程安全的代码,包括 synchronized 关键字、volatile 变量、原子类、Locks、并发集合等。

  4. happens-before 原则:happens-before 是 Java 内存模型中定义的一个偏序关系,用来指定两个操作之间的执行顺序。如果操作 A happens-before 操作 B,则操作 A 的结果对操作 B 是可见的,且操作 B 不会重排序到操作 A 之前执行。

  5. 内存屏障(Memory Barrier):内存屏障是一种硬件或者编译器指令,用来强制刷新处理器缓存,确保执行顺序的正确性。在 Java 中,volatile 关键字和 synchronized 块都会涉及到内存屏障的使用。

【未整理】Java内存模型与线程

Java 内存模型(Java Memory Model,JMM)定义了 Java 程序中多线程并发访问共享内存时的行为规范,确保不同线程之间的内存可见性、顺序性和原子性。理解 Java 内存模型对于编写正确并发程序至关重要。

【未整理】线程安全与锁优化

线程安全

✅线程安全是指在多线程环境中,保证共享资源(如对象、变量等)被多个线程访问时仍能保持其正确性的特性。

在实现线程安全的过程中,锁起着关键作用,控制多线程对共享资源的使用,从而避免数据竞争和不一致性。

  1. 锁的种类:
    • 悲观锁:假设多个线程会同时访问共享资源,并且可能会发生竞争和冲突,因此在访问前先获取锁,并在使用后释放锁。常见的悲观锁包括 synchronized 关键字和 ReentrantLock 类。
    • 乐观锁:假设多个线程在访问共享资源时不会发生竞争和冲突,因此不需要加锁,而是在更新操作时通过版本号或 CAS(Compare and Swap)等机制进行冲突检测和解决。乐观锁适合读多写少的场景,例如基于版本号的 MVCC(Multi-Version Concurrency Control)机制。
  2. 锁优化技术
    • 细粒度锁:尽可能缩小锁的范围,减少锁的持有时间,以提高并发度和减少竞争。例如,使用 ConcurrentHashMap 可以将 Map 的不同部分分别加锁,提高并发性能。
    • 锁分离:将读写操作分开加锁,允许多个线程同时读取共享资源,只有在写操作时才加锁,例如 ReadWriteLock 接口和其实现类 ReentrantReadWriteLock。
    • 锁粗化:将多个连续的小锁合并为一个大锁,减少加锁和释放锁的次数,提高程序性能。适用于多个连续的临界区操作。
    • 偏向锁和轻量级锁:针对只有一个线程访问共享资源的场景,引入偏向锁和轻量级锁,减少传统锁的性能开销。偏向锁在竞争出现前会尝试给线程加锁,避免了多次加锁和解锁的开销;轻量级锁利用 CAS 操作来减少锁的争用。
    • 适应性自旋:在加锁失败时,线程不立即阻塞,而是通过自旋等待一定的时间,如果等待时间较短,可以避免线程切换的开销。

锁优化

锁优化技术在多线程编程中的具体场景可以根据不同的需求和并发访问模式选择合适的策略,以下是几种常见的场景和相应的锁优化技术:

  1. 细粒度锁和锁分离:
    • 场景描述:当多个线程并发访问一个共享数据结构,但只有部分操作是写操作,其他操作是读操作。
    • 优化技术:使用 ReadWriteLock 接口和其实现类 ReentrantReadWriteLock,允许多个线程同时读取共享资源,只有在写操作时才加锁。这样可以提高读操作的并发性能,减少读-读之间的竞争。
  2. 锁粗化:
    • 场景描述:存在多个连续的对共享资源的独立操作,每次操作都需要获取锁。
    • 优化技术:将多个连续的小锁操作合并为一个大锁操作,减少频繁加锁和释放锁的开销。例如,在遍历一个数据结构并逐个操作元素时,可以考虑在遍历的过程中只加一次锁,执行完所有操作后再释放锁。
  3. 偏向锁和轻量级锁:
    • 场景描述:大部分情况下,共享资源只有一个线程在访问,存在较少的竞争。
    • 优化技术:引入偏向锁和轻量级锁来减少传统锁的性能开销。偏向锁在初次访问时为线程加锁,并标记为偏向锁状态,避免了每次锁竞争时都需要使用 CAS 操作;轻量级锁在线程间竞争较少时通过 CAS 操作,避免了传统锁的重量级操作。
  4. 适应性自旋:
    • 场景描述:在高并发情况下,短暂的自旋等待可以避免线程频繁阻塞和唤醒带来的性能损失。
    • 优化技术:当线程尝试获取锁失败时,不立即阻塞线程,而是采用自旋等待一段时间,如果在自旋等待期间锁被释放,则避免了线程切换的开销。适应性自旋可以根据实际情况动态调整自旋等待的时间。
  5. 分段锁:
    • 场景描述:当一个大的数据结构被多个线程访问时,可以将数据结构分成多个小段,每个段独立加锁。
    • 优化技术:使用分段锁(Striped Locking),将一个大的共享数据结构分解成多个小的部分,每个部分都有自己的锁。这样可以减少锁竞争的范围,提高并发访问性能。
  6. 锁的超时和中断:
    • 场景描述:在某些情况下,线程尝试获取锁时可以设定超时时间,避免长时间阻塞;或者允许线程在等待锁时可以被中断,响应中断事件。
    • 优化技术:使用带超时参数的锁获取方法,允许线程在获取锁失败后等待一定时间再重试,避免长时间阻塞。另外,可以在等待锁的过程中响应中断信号,及时处理中断事件。