真正理解synchronized:从使用到原理的深度解析

在 Java 多线程编程中,如何确保共享资源在多线程环境下的安全访问,是开发者必须面对的核心问题。synchronized关键字作为 Java 语言层面提供的同步利器,自 Java 诞生起就承担着保障线程安全的重任。本文将从ynchronized的基础使用、核心特性,到其背后的可见性、有序性保障原理,以及锁升级机制和底层实现,进行全面且深入的剖析,带你彻底掌握这一并发编程的关键技术。

首先进行总结一下,方便有基础的同学食用。

博主总结(重要)

  • synchronized(可重入、非公平、不可中断)
    • synchronized使用
      • 同步方法:为当前对象(this)加锁,进入同步代码前要获得当前对象的锁;
      • 同步静态方法:为当前类加锁(锁的是 Class 对象),进入同步代码前要获得当前类的锁;
      • 同步代码块:指定加锁对象可以是任意对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
      • 注意:构造方法不能使用 synchronized 关键字修饰。不过,可以在构造方法内部使用 synchronized 代码块。
      • 加深理解:可以看习题.线程8锁问题。我总结的答案是:锁住同一对象,都有可能。锁住不同对象,只有一种可能。
    • synchronized 怎么保证可见性?
      • 加锁时,线程必须从主内存读取最新数据。
      • 释放锁时,线程必须将修改的数据刷回主内存,这样其他线程获取锁后,就能看到最新的数据。
    • synchronized 怎么保证有序性?
      • synchronized 通过 JVM 指令 monitorenter 和 monitorexit,来确保加锁代码块内的指令不会被重排。

    • synchronized 怎么实现可重入的呢?(类似偏向锁机制)
      • synchronized 之所以支持可重入,是因为 Java 的对象头包含了一个 Mark Word,用于存储对象的状态,包括锁信息。
      • 底层是通过 Monitor 对象的 owner 和 count 字段实现的,owner 记录持有锁的线程,count 记录线程获取锁的次数。
    • synchronized锁升级?
      • 升级原因:为了提升 synchronized 的性能,引入了锁升级机制,从低开销的锁逐步升级到高开销的锁,以最大程度减少锁的竞争。
      • synchronized四种状态?适用场景?无锁 -> 偏向锁(可看作可重入锁) -> 轻量级锁 (可看作乐观锁)-> 重量级锁。
        • ”偏向锁“,适用于单线程访问同步代码块或方法,即没有线程竞争,此时没有额外的 CAS 操作;

        • 轻度竞争时使用“轻量级锁”,适用于虽然是多线程,但是是在不同时段访问同步块和资源,即没有锁竞争的情况。采用 CAS 自旋,避免线程阻塞,如果失败就说明发生锁竞争了,这时就要升级为重量级锁了。
        • 只有在重度竞争时,才使用“重量级锁”,由 Monitor 机制实现,需要线程阻塞。
      • 锁升级流程
        • 前情知识:Mark Word

        • 偏向锁 的获得和撤销:

        • 轻量级锁 加锁和释放锁的过程:

    • 底层:对象监视器monitor,可以学习进阶一下。

一、synchronized 的基础使用

(一)同步方法

同步方法是synchronized最常见的使用形式之一,分为实例同步方法和静态同步方法。

实例同步方法是为当前对象(this)加锁,当一个线程调用实例同步方法时,必须先获得当前对象的锁才能进入同步代码。例如:

public class SynchronizedExample {

    public synchronized void syncMethod() {
        // 同步执行的业务逻辑
    }

}
 

在上述代码中,任何线程想要执行syncMethod方法,都需要获取SynchronizedExample实例的锁。同一时刻,只有一个线程能持有该锁并执行方法体,其他线程只能等待锁释放后再尝试获取。

静态同步方法则是为当前类加锁,锁的对象是类的Class对象。由于静态方法属于类级别,所以静态同步方法的锁对所有类的实例都生效。示例如下:

public class StaticSynchronizedExample {

    public static synchronized void staticSyncMethod() {
        // 同步代码逻辑
    }

}
 

无论创建多少个StaticSynchronizedExample类的实例,这些实例在调用staticSyncMethod时,竞争的都是同一个Class对象锁。

(二)同步代码块

同步代码块允许开发者显式指定加锁对象,这个对象可以是任意对象。这种灵活性使得同步代码块能在更细粒度上控制同步范围,提升程序性能。例如:

public class BlockSynchronizedExample {

    // 使用私有final对象作为锁
    private final Object lock = new Object();

    // 同步方法实现
    public void blockSyncMethod() {
        // 对lock对象进行同步控制
        synchronized (lock) {
            // 同步执行的业务逻辑
        }
    }
}
 

在上述代码中,线程进入blockSyncMethod方法内的同步代码块时,需要获取lock对象的锁。通过合理选择锁对象,可以避免对不必要的资源进行锁定,减少锁竞争。

(三)使用注意事项

需要特别注意的是,构造方法不能使用synchronized关键字修饰。因为构造方法的作用是创建对象,在对象尚未完全初始化完成时加锁可能会引发各种问题,并且构造方法本身的执行在单线程环境下进行,不存在线程安全问题。不过,在构造方法内部可以使用synchronized代码块,对构造过程中涉及的共享资源进行同步操作。

class Resource {

    private Object sharedResource;

    public Resource() {
        synchronized (this) {
            // 初始化共享资源
            sharedResource = new Object();
        }
    }
}
 

(四)深入理解:线程 8 锁问题

通过 “线程 8 锁问题” 能更好地理解synchronized的锁机制。简单来说,当多个线程访问不同的同步方法或同步代码块时,如果锁住的是同一对象,由于同一时刻只有一个线程能获取该对象的锁,所以线程执行顺序存在多种可能性;而如果锁住的是不同对象,每个线程获取各自对象的锁互不干扰,执行顺序相对固定,往往只有一种确定的执行情况 。这一问题的深入分析,能帮助开发者更清晰地把握synchronized的锁对象和执行逻辑。具体问题可以网上搜寻,下面给出一个例子。

二、synchronized 的核心特性

(一)可重入性

synchronized具有可重入性,即同一个线程在持有某个对象锁的情况下,可以再次获取该对象的锁,而不会发生死锁。这一特性的实现依赖于 Java 对象头中的Mark Word和Monitor对象。

Mark Word用于存储对象的状态,包括锁信息。而Monitor对象内部的owner字段记录持有锁的线程,count字段记录线程获取锁的次数。当线程第一次获取锁时,Monitor的owner被设置为该线程,count置为 1;当线程再次获取同一把锁,count递增;释放锁时,count递减,当count为 0 时,owner被清空,锁被释放。这种机制使得线程在递归调用同步方法或嵌套同步代码块时,能够顺利执行,不会出现自己阻塞自己的情况。

(二)非公平性

synchronized属于非公平锁,这意味着当锁被释放时,新到达的线程和等待队列中的线程都有机会竞争锁,新线程可能会插队获取锁,而不是按照请求锁的先后顺序。

与公平锁相比,非公平锁在某些场景下能提高性能。因为公平锁需要维护一个严格的等待队列,每次锁释放时都要从队列头部唤醒线程,这会带来额外的上下文切换和队列管理开销。而synchronized的非公平性减少了这些开销,新线程可以直接尝试获取锁,如果成功则避免了线程阻塞和唤醒的开销。但这种特性也可能导致等待队列中的线程长时间无法获取锁,出现线程饥饿问题。

(三)不可中断性

当一个线程在等待获取synchronized锁时,它不能被其他线程通过interrupt方法中断。该线程将一直处于阻塞状态,直到锁被释放并成功获取锁为止。这与ReentrantLock等可中断锁不同,可中断锁允许线程在等待锁的过程中响应中断,当收到中断信号时,可中断锁会抛出InterruptedException并停止等待。而synchronized的不可中断性,是由其基于Monitor机制实现锁获取和释放的特性决定的,在等待锁期间,线程处于BLOCKED状态,interrupt方法对其无效。

三、synchronized 如何保证可见性与有序性

(一)保证可见性的原理

可见性是指一个线程对共享变量的修改能够及时被其他线程看到。synchronized通过 Java 内存模型(JMM)来保证可见性。

当线程获取synchronized锁时,它必须从主内存读取最新的数据到工作内存;当线程释放synchronized锁时,它必须将工作内存中修改的数据刷回主内存。JMM 规定,对一个变量的解锁操作happens-before于后续对这个变量的加锁操作。这就确保了一个线程释放锁后,其他线程获取同一个锁时,能够看到前一个线程对共享变量所做的修改,从而保证了数据的可见性。

(二)保证有序性的原理

有序性是指程序执行的顺序按照代码的先后顺序执行,避免指令重排导致的程序逻辑错误。synchronized通过 JVM 指令monitorenter和monitorexit来确保加锁代码块内的指令不会被重排。

monitorenter和monitorexit指令形成了一个内存屏障(Memory Barrier),它禁止了指令在屏障前后的重排。当线程执行到monitorenter指令时,它会将工作内存中的数据刷新到主内存,并清空工作内存;当执行到monitorexit指令时,它会将工作内存中修改的数据刷回主内存。这样就保证了在synchronized代码块内的指令执行顺序与代码编写顺序一致,实现了有序性。

四、synchronized 的锁升级机制

(一)锁升级的原因

在早期 Java 版本中,synchronized直接使用重量级锁,这种方式在锁竞争不激烈时会带来较高的性能开销,因为线程获取和释放重量级锁需要进行用户态到内核态的切换,涉及操作系统的线程调度和资源分配。

为了提升synchronized的性能,从 JDK 1.6 开始引入了锁升级机制,使得synchronized能够根据实际的锁竞争情况,从低开销的锁逐步升级到高开销的锁,以最大程度减少锁的竞争和资源消耗,提高程序的并发性能。

(二)四种锁状态及适用场景

  1. 无锁状态:对象刚创建时处于无锁状态,此时对象头的Mark Word存储对象的哈希码、分代年龄等信息。多个线程可以并发访问对象的非同步方法,不会发生锁竞争。
  1. 偏向锁:适用于单线程访问同步代码块或方法的场景,即没有线程竞争的情况。在这种状态下,对象头的Mark Word中记录持有锁的线程 ID,当该线程再次访问同步代码时,无需进行额外的CAS(Compare - And - Swap)操作来获取锁,直接进入同步代码块,从而提高了执行效率。当有其他线程尝试访问同步代码时,偏向锁会升级为轻量级锁。
  1. 轻量级锁:适用于多线程在不同时段访问同步块和资源,即存在轻度竞争的情况。在轻量级锁状态下,通过CAS自旋操作来避免线程阻塞。当线程尝试获取轻量级锁时,会使用CAS操作将对象头中的Mark Word替换为指向线程栈中锁记录的指针。如果CAS操作成功,则获取锁;如果失败,则说明发生了锁竞争,此时轻量级锁会升级为重量级锁。
  1. 重量级锁:适用于重度竞争场景,当多个线程频繁竞争同一把锁时,轻量级锁的CAS自旋操作会消耗大量的 CPU 资源,此时锁会升级为重量级锁。重量级锁由Monitor机制实现,当线程获取不到锁时,会被阻塞并放入等待队列,需要操作系统进行线程调度来唤醒线程,这种方式会带来较高的上下文切换开销,但在重度竞争下能够保证线程安全。

(三)锁升级流程详解

  1. 前情知识:Mark Word:Mark Word是对象头的重要组成部分,在不同的锁状态下,Mark Word存储着不同的信息,如锁标志位、持有锁的线程 ID 等。它是实现锁升级的关键数据结构,记录着对象当前的锁状态,JVM 根据Mark Word的状态来判断是否需要进行锁升级以及如何进行锁操作 。
  1. 偏向锁的获得和撤销:当一个线程首次访问synchronized修饰的代码块时,会尝试获取偏向锁。JVM 会检查对象头的Mark Word是否处于可偏向状态,如果是,则使用CAS操作将Mark Word中的线程 ID 设置为当前线程 ID,从而获取偏向锁。当有其他线程尝试获取同一对象的锁时,偏向锁会被撤销。撤销过程中,首先会暂停拥有偏向锁的线程,检查该线程是否还在执行同步代码。如果线程已经执行完毕,则直接将对象头恢复为无锁状态;如果线程还在执行,则将偏向锁升级为轻量级锁。
  1. 轻量级锁的加锁和释放锁的过程:当线程尝试获取轻量级锁时,它会在自己的栈帧中创建一个锁记录(Lock Record),并将对象头的Mark Word复制到锁记录中。然后,线程使用CAS操作将对象头的Mark Word替换为指向锁记录的指针。如果CAS操作成功,则线程获取到轻量级锁,进入同步代码块;如果CAS操作失败,说明其他线程已经持有锁,此时线程会进行一定次数的CAS自旋尝试获取锁。如果自旋次数达到阈值后仍然无法获取锁,则轻量级锁升级为重量级锁,线程进入阻塞状态。轻量级锁的释放过程相对简单,线程执行完同步代码块后,会使用CAS操作将锁记录中的Mark Word替换回对象头。如果CAS操作成功,说明没有其他线程竞争锁,锁被成功释放;如果CAS操作失败,说明有其他线程在竞争锁,此时需要唤醒等待队列中的线程,将锁升级为重量级锁。

五、synchronized 的底层实现:对象监视器 monitor

对象监视器(Monitor)是 JVM 为每个 Java 对象创建的用于实现同步的底层数据结构,它是synchronized实现的核心。Monitor在底层由 C++ 实现,每个 Java 对象都关联着一个Monitor对象。

Monitor对象包含多个关键属性:

  • _count:表示锁计数器,记录线程获取锁的次数,用于实现可重入性。
  • _owner:指向持有锁的线程,标识当前拥有锁的线程。
  • _waitset:是调用wait()方法的线程集合,线程调用wait()方法后会进入该集合等待被唤醒。
  • _entrylist:存放等待获取锁的线程队列,当线程获取锁失败时,会进入该队列等待。
  • _spinFreq_spinClock:用于控制自旋策略,_spinFreq表示获取锁之前的自旋次数,_spinClock表示自旋间隔时间周期,通过合理设置这两个参数,可以在一定程度上减少线程阻塞带来的开销 。

当线程尝试访问synchronized修饰的代码时,实际上是在操作对应的Monitor对象。线程首先尝试获取_owner引用,如果_owner为null,则线程获取锁并设置_owner为自身,同时递增_count;如果_owner不为null且为当前线程,则递增_count实现可重入;如果_owner为其他线程,线程会根据情况进入_entrylist等待队列或进行自旋尝试获取锁。释放锁时,线程递减_count,当_count为 0 时,清空_owner,并根据_waitset和_entrylist的情况唤醒相应的线程。

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

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

    抵扣说明:

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

    余额充值