JAVA线程中synchronized的四种锁状态详解

1.锁的基本使用方法

  1. 实例方法同步:指的是锁定了当前对象(即调用该方法的对象实例)的同步状态。如果一个类中的实例方法被标记为 `synchronized`,那么在任意时刻,只有一个线程可以执行该对象的所有同步实例方法。
  2. 静态方法同步:这里的“类”指的是类本身,而不是类的任何特定实例。静态方法是与类相关联的,而不是与类的任何特定实例相关联。因此,静态方法同步锁定的是整个类的同步状态,影响所有实例对该静态方法的访问。
// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
    // code
}

// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    Object o = new Object();
    synchronized (o) {
        // code
    }
}
// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
    // code
}

当一个实例方法被声明为synchronize时,锁时当前实例对象(this)。这意味着同一时间只有一个线程可以执行该实例的所有同步实例方法,如果多个线程尝试访问同一个对象的同步实例方法,它们将会被序列化,一个接着一个执行。

// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
    // code
}

当一个静态方法被声明为synchronize时,锁时这个类的class类。这意味着同一时间只有一个县城可以执行该类的所有同步静态方法。这与实例方法同步不同,因为他锁定的是类本身而不是类的某个特定的实例。

这里介绍一下“临界区”的概念。所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行。在上面的例子中,如果synchronized关键字在方法上,那临界区就是整个方法内部。而如果是 synchronized 代码块,那临界区就指的是代码块内部的区域。这是为了确保共享资源的一致性和完整性,防止多个线程同时访问和修改共享资源,从而避免数据竞争(Race Condition)和不一致的问题。

同步方法: 

当使用synchronized关键字修饰一个方法时,整个方法体就是一个临界区。这意味着,当一个线程执行这个方法时,其他线程将无法执行该对象的任何其他同步方法。

public class Counter {
    private int count = 0;

    // 整个方法是一个临界区
    public synchronized void increment() {
        count++;
    }
}

在这个例子中,increment方法是同步的,因此他是一个临界区。当一个线程调用increment方法时,其他线程将被阻塞,直到当前线程执行完毕。

同步代码块:

当需要同步的代码只是方法的一部分时,可以使用同步代码块。同步代码块允许你指定一个锁对象,并只同步访问该对象的代码块。 

public class Counter {
    private int count = 0;
    private final Object lock = new Object(); // 锁对象

    public void increment() {
        synchronized (lock) {
            // 只有这段代码是一个临界区
            count++;
        }
    }
}

在这个例子当中,increment方法中同步代码指定了lock对象作为锁。只有当一个线程进入到这个代码块中的时候,它才会持有锁,并且其他线程将无法进入到同步代码块中,直到锁被释放。

使用同步代码块可以更细粒度地控制同步的范围,这有助于减少线程阻塞的时间,提高程序的并发性能。但是,开发者需要更加小心地管理锁对象,以避免死锁等问题。

2.锁的四种状态和锁降级

在 JDK 1.6 以前,所有的锁都是”重量级“锁,因为使用的是操作系统的互斥锁,当一个线程持有锁时,其他试图进入synchronized块的线程将被阻塞,直到锁被释放。涉及到了线程上下文切换和用户态与内核态的切换,因此效率较低。

这也是为什么很多开发者会认为 synchronized 性能很差的原因。

那为了减少获得锁和释放锁带来的性能消耗,JDK 1.6 引入了“偏向锁”和“轻量级锁” 的概念,对 synchronized 做了一次重大的升级,升级后的 synchronized 性能可以说上了一个新台阶。

在 JDK 1.6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

 无锁状态:

在无锁状态下,没有线程访问共享资源,或者只有一个线程访问共享资源,不需要同步访问,任何线程都可以尝试去修改它。

偏向锁状态:

偏向锁是jvm为了减少无竞争情况下的锁开销而引入的,当一个线程第一次访问同步块的时候,jvm会自将其设置为偏向锁,并将线程id记录到对象头中。如果后续仍然是同一个线程党文,就不需要进行同步控制了。

轻量锁状态:

当有其他线程尝试访问同一个同步块时,JVM会将偏向锁升级为轻量级锁。避免了线程的阻塞和上下文的切换。

重量级锁状态:

如果轻量级锁下面发生了线程竞争,JVM会将轻量级锁升级为重量级锁,重量级锁依赖于操作系统的互斥锁实现,此时会发生线程阻塞和上下文切换。

锁的优缺点: 

 2.1偏向锁的实现原理

一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID。当下次该线程进入这个同步块时,会去检查锁的 Mark Word 里面是不是放的自己的线程 ID。

如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费 CAS 操作来加锁和解锁;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用 CAS 来替换 Mark Word 里面的线程 ID 为新线程的 ID,这个时候要分两种情况:

  • 成功,表示之前的线程不存在了, Mark Word 里面的线程 ID 为新线程的 ID,锁不会升级,仍然为偏向锁;
  • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为 0,并设置锁标志位为 00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

CAS: Compare and Swap 会在后面细讲,可戳链接直达,这里简单提一嘴。

CAS 是比较并设置的意思,用于在硬件层面上提供原子性操作。在 在某些处理器架构(如x86)中,比较并交换通过指令 CMPXCHG 实现((Compare and Exchange),一种原子指令),通过比较是否和给定的数值一致,如果一致则修改,不一致则不修改。

线程竞争偏向锁的过程如下:   图中涉及到了 lock record 指针指向当前堆栈中的最近一个 lock record,是轻量级锁按照先来先服务的模式进行了轻量级锁的加锁。             

  • 轻量级锁的获取:当一个线程尝试获取一个对象的锁时,如果该对象的锁状态是偏向锁或者无锁状态,并且对象头的Mark Word中没有指向栈帧中的lock record的指针,JVM会首先尝试将对象头都Mark Word中的锁指针当前线程的栈帧中的一个lock record。
  • lock record的作用:lock record的是线程栈中的一个特殊记录,它保存了锁对象的引用和锁的状态信息。当轻量级锁被激活的时候,JVM会在当前线程中创建一个lock record,并尝试通过CAS操作将对象头的锁指针指向这个lock record。

撤销偏向锁

偏向锁是一种轻量级的锁机制,它在多线程环境中用于减少锁的开销。

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:

  1. 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
  2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和 Mark Word,使其变成无锁状态。
  3. 唤醒被停止的线程,将当前锁升级成轻量级锁。

如果代码中需要撤销偏向锁,通常不需要手动操作,因为JVM会根据进行时的情况进行调整。但是,如果你想要对锁的行为进行更细致的控制,可以通过设置JVM参数来影响锁的机制。

所以,如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:

-XX:UseBiasedLocking=false

  1. 竞争发生:当第二个线程尝试获取这个锁的时候,JVM会检测到偏向锁的存在,并尝试撤销偏向锁的状态。
  2. 撤销偏向锁:JVM会将所对象的Mark Word中的线程ID清零,并设置偏向模式为非偏向(01),表示锁不再偏向任何的线程。

2.2 轻量级锁

多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM 采用轻量级锁来避免线程的阻塞与唤醒。轻量级锁是一个在多线程环境中用来控制对共享资源访问的同步机制。它通常用于那些竞争激烈,但是锁持有时间较短的场景。

轻量级锁的目的是减少线程在获取锁时的开销,提高系统的并发性能。 

JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为 Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的 Mark Word 复制到自己的 Displaced Mark Word 里面。

然后线程尝试用 CAS 将锁的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示 Mark Word 已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。

自旋:不断尝试去获取锁,一般用循环来实现。

自旋是需要消耗 CPU 的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费 CPU 资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环 10 次,如果还没获取到锁就进入阻塞状态。

但是 JDK 采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

自旋也不是一直进行下去的,如果自旋到一定程度(和 JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁

2.3 锁的释放

  1. 更新锁状态:线程在释放锁之前,需要将锁的状态更新为未锁定状态,这通常需要设计到将锁的Mark Word或锁记录恢复到原始状态。
  2. 唤醒等待线程:在某些锁实现中,如果有其他线程正在等待这个锁,持有锁的线程线程放锁的时候需要唤醒至少一个i等待线程。这可以通过操作系统的线程调度机制来实现。
  3. 在释放锁时,当前线程会使用 CAS 操作将 Displaced Mark Word 的内容复制回锁的 Mark Word 里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么 CAS 操作会失败,此时会释放锁并唤醒被阻塞的线程。

一张图说明加锁和释放锁的过程:

 轻量级锁获取流程:

1. 检查锁状态:线程首先检查对象的Mark Word来确定锁的状态。如果对象未被锁定,即Mark Word表示的是对象的原始状态,线程将尝试进入轻量级锁状态。

2. 复制Mark Word:如果对象未锁定,线程将对象的Mark Word复制到自己的Displaced Mark Word中,这是当前线程栈帧中用于存储锁记录的空间。

3. CAS操作:线程使用CAS(CompareAndSwap)操作尝试将对象的Mark Word替换为指向线程栈帧中锁记录的指针。如果CAS成功,线程获得轻量级锁。

4. 自旋等待:如果CAS失败,表示其他线程正在尝试获取同一锁,当前线程将进入自旋状态,不断尝试CAS操作直到成功或自旋一定次数后失败。

5. 适应性自旋:JVM的自旋机制是适应性的,根据自旋成功或失败的历史调整自旋次数。

 轻量级锁膨胀流程:

1. 自旋失败:如果线程在自旋一定次数后仍未获得锁,自旋失败,此时线程将尝试进入阻塞状态。

2. 锁升级:轻量级锁升级为重量级锁。这涉及到将对象的Mark Word替换为指向重量级锁的指针,并将锁对象的owner设置为当前线程。

3. 线程阻塞:当前线程被阻塞,等待锁的释放。同时,其他等待该锁的线程也将进入阻塞状态。

4. 唤醒等待线程:当持有重量级锁的线程释放锁时,它会唤醒至少一个等待该锁的线程。

5. 锁的重置:在锁被释放时,需要将对象的Mark Word重置为原始状态,以便其他线程可以重新尝试获取轻量级锁。

6. 锁的降级:在某些情况下,如果重量级锁之后没有竞争,JVM可能会将锁降级回轻量级锁或偏向锁状态,以减少锁的开销。

2.4 重量级锁

重量级锁依赖于操作系统中的互斥锁,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。

特点:

  1. 资源保护:通过锁定资源,确保同一时间只有一个线程可以访问该资源。
  2. 内核态操作:重量级锁的获取和释放通常需要操作系统内核介入,这涉及到用户态到内核态的切换,代价较高。
  3. 性能开销:由于需要操作系统介入,重量级锁在高并发环境下可能会成为性能瓶颈。

前面说到,每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程: 

 

1. Contention List(竞争队列):当一个线程想要获取一个已经被其他线程持有的锁时,它会首先被放入这个队列。

2. Entry List(候选队列):从竞争队列中,一些符合条件的线程会被移动到这里,它们有资格成为下一个获取锁的候选者。

3. Wait Set(等待集合):如果线程在持有锁的同时调用了`wait()`方法,它会释放锁并进入等待状态,这时线程会被放入这个集合。

4. OnDeck(当前竞争者):在任何时刻,只有一个线程可以是“当前竞争者”,它正在尝试获取锁。

5. Owner(锁拥有者):成功获取锁的线程被称为“锁拥有者”。

6. !Owner(非锁拥有者):释放了锁的线程。

7. ObjectWaiter对象:当线程尝试获取被占用的锁时,它会创建这个对象,然后被放入竞争队列,并挂起等待。

8. Heir Presumptive(假定继承人):当锁被释放时,系统会从竞争队列或候选队列中选择一个线程作为“假定继承人”,它将尝试获取锁,但并不保证一定能成功,因为`synchronized`是不公平的,也就是说它不保证按照请求锁的顺序来分配锁。

9. 重量级锁的阻塞:如果线程尝试获取锁失败,它不会继续消耗CPU资源去尝试获取锁,而是进入阻塞状态,等待操作系统的调度。

10. WaitSet到Contention List/EntryList的移动:当在等待集合中的线程被`notify()`或`notifyAll()`唤醒时,它会重新进入竞争队列或候选队列,准备再次尝试获取锁。

11. 锁的膨胀:如果一个线程调用了锁对象的`wait()`或`notify()`方法,而锁之前是偏向锁或轻量级锁的状态,那么它会“膨胀”成为重量级锁,以便能够处理等待和唤醒的操作。

3.锁的升级流程 

1. 偏向锁:当一个线程第一次访问某个对象的同步代码块时,Java虚拟机会在对象头(MarkWord)中记录下这个线程的ID。如果下次还是同一个线程访问,它会发现MarkWord里的ID是自己的,这样它就不需要额外的同步操作,直接进入同步块,这就是偏向锁。

2. 锁升级:如果有另一个线程尝试访问同一个同步代码块,它会发现MarkWord里的ID不是自己的。这时,Java虚拟机会尝试升级锁的状态,从偏向锁变为轻量级锁。

3. CAS操作:升级过程中,新线程会使用CAS(Compare-And-Swap)操作尝试替换MarkWord的内容,把自己的线程信息写入其中。如果成功,它就获得了轻量级锁。

4. 自旋:如果CAS操作失败,也就是说有其他线程也在尝试获取锁,那么当前线程可能会进入自旋状态,即不断尝试获取锁,但不立即放弃。

5. 自旋成功:如果在自旋过程中,之前的线程完成了同步代码块的执行并释放了锁,那么自旋的线程就有机会通过CAS操作获得锁,锁的状态仍然是轻量级的。

6. 自旋失败:如果自旋的线程尝试了多次CAS操作仍然失败,或者检测到有其他线程正在等待这个锁,Java虚拟机会决定将锁升级为重量级锁。

7. 重量级锁:一旦升级为重量级锁,自旋的线程就会被操作系统挂起,进入阻塞状态。这意味着它不再消耗CPU资源去尝试获取锁,而是等待操作系统的调度。当锁被释放时,操作系统会唤醒这个线程,让它再次尝试获取锁。

简单来说,Java中的锁机制是动态的,它会根据线程访问共享资源的情况自动调整锁的状态。从偏向锁到轻量级锁,再到重量级锁,这个过程中涉及到线程ID的比较、CAS操作、自旋以及线程的阻塞和唤醒。这种机制的目的是为了在保证线程安全的同时,尽可能减少同步操作的性能开销。

  • 29
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值