【JavaEE】synchronized原理 和 Reentrantlock

   🔥个人主页: 中草药

🔥专栏:【Java】登神长阶 史诗般的Java成神之路


🩺一.synchronized原理

基本原理

synchronized关键字的基本原理是通过使用互斥锁(也称为独占锁)来实现线程间的同步。当一个线程获取了一个对象的锁后,其他试图获取该对象锁的线程将会被阻塞,直到锁被释放。

基本特点

结合上一篇文章的锁策略, 我们就可以总结出, synchronized 具有以下特性(只考虑 JDK 1.8):
  1. 开始时是乐观锁,如果锁冲突频繁,就转化为悲观锁
  2. 开始是轻量级锁实现,如果锁被持有的时间较长,就转化为重量级锁
  3. 实现轻量级锁的时候,大概率用到自旋锁策略
  4. 是一种不公平锁
  5. 是一种可重入锁
  6. 不是读写锁

锁升级

  1. 无锁状态

    • 初始状态下,对象处于无锁状态,没有任何线程持有锁。
    • 当一个线程首次尝试获取锁时,锁会从无锁状态升级到轻量级锁状态。
  2. 偏向锁状态

    • 偏向锁是Java 6中引入的一种锁优化技术,旨在减少无竞争情况下的同步开销。
    • 当一个线程第一次访问一个对象的同步代码块时,该线程会获得一个偏向锁,以后该线程再次访问时,可以直接进入同步代码块而无需加锁。
    • 如果有其他线程尝试获取锁,偏向锁将被撤销,并升级为轻量级锁或重量级锁。
    • 偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
      但是该做的标记还是得做的, 否则⽆法区分何时需要真正加锁
  3. 轻量级锁状态

    • 当线程尝试获取锁时,如果当前没有其他线程持有该锁,线程将尝试使用CASS(Compare and Swap)操作来获取锁。
    • CASS操作会将线程ID写入对象头的Mark Word中,如果CASS操作成功,则线程获得了锁,锁的状态变为轻量级锁。
    • 如果此时有多个线程尝试获取锁,其中一个线程成功获取了锁,而其他线程的CASS操作将失败,这时轻量级锁将升级为重量级锁。如果更新失败, 则认为锁被占⽤, 继续⾃旋式的等待(并不放弃 CPU)
    • 自旋操作是⼀直让 CPU 空转, ⽐较浪费 CPU 资源.
      因此此处的⾃旋不会⼀直持续进⾏, ⽽是达到⼀定的时间/重试次数, 就不再⾃旋了.
      也就是所谓的 "自适应"
  4. 重量级锁状态

    • 如果竞争进⼀步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁,当轻量级锁升级为重量级锁时,锁的实现将使用操作系统层面的互斥锁(Mutex Lock)。
    • 重量级锁涉及到线程的挂起和唤醒,开销较大,但可以确保线程间的同步。
    • 当重量级锁被释放时,如果还有其他线程等待获取锁,这些线程将被唤醒并竞争锁。

其他的优化操作

锁消除(Lock Elimination)

原理

锁消除是指在编译阶段,JVM编译器通过分析代码来判断某些synchronized块中的锁是否真的有必要。如果编译器确定某些锁是不必要的,那么它会在编译时直接去除这些锁,从而避免运行时的锁开销。

何时发生

锁消除通常发生在以下几种情况:

  1. 不可变对象

    • 如果一个对象是不可变的,并且在synchronized块中只读取该对象的状态而不修改它,那么编译器可能会消除该锁。
    • 因为不可变对象的状态一旦创建就不会改变,所以不需要对其进行同步。
  2. 局部变量

    • 如果synchronized块中使用的对象仅在该块内部使用,并且不会被其他线程访问,那么编译器可能会消除该锁。
    • 这是因为局部变量不会被多个线程共享,所以不需要同步。
  3. 单线程访问

    • 如果编译器可以确定一个synchronized块只能被单个线程访问,那么它可以消除该锁。
    • 这种情况通常出现在非多线程环境中,或者在单线程的回调函数中。

有些应⽤程序的代码中, ⽤到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer-线程安全的容器)  

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解 锁操作是没有必要的, 白白浪费了⼀些资源开销

锁粗化(Lock Coarsening)

锁的粒度

        锁的粒度是指在多线程环境下,锁保护的数据范围的大小。锁的粒度决定了锁的精细程度,进而影响并发性能和数据一致性。锁的粒度越细,意味着锁保护的数据范围越小,这样可以减少锁的竞争,提高并发性能。相反,锁的粒度越粗,锁保护的数据范围越大,可能会增加锁的竞争,但可以简化同步逻辑。下面详细解释锁的粒度及其对程序的影响。

原理

锁粗化是指将一系列连续的synchronized代码块合并成一个大的synchronized块,以减少锁的获取和释放次数。这是因为锁的获取和释放涉及到一些额外的开销,比如线程上下文切换等。

实际开发过程中, 使⽤细粒度锁, 是期望释放锁的时候其他线程能使⽤锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会⾃动把锁粗化, 避免频繁申请释放锁.

何时发生

锁粗化通常发生在以下几种情况:

  1. 连续的synchronized

    • 如果一系列连续的synchronized块作用于同一个对象,那么JVM可能会将它们合并成一个大的synchronized块。
    • 这样可以减少锁的获取和释放次数,从而提高性能。
  2. 短时间间隔

    • 如果连续的synchronized块之间的间隔非常短,那么合并它们通常是有利的。
    • 这是因为短时间内多次获取和释放锁的开销大于合并后的锁开销。

 🩹二.ReentrantLock

ReentrantLock是Java中一个可重入的互斥锁,它是java.util.concurrent.locks包中提供的一个类。ReentrantLock提供了比内置的synchronized关键字更强大的锁定机制,并且可以显式地控制锁的获取和释放,从而提供了更高的灵活性和更好的性能。下面详细介绍ReentrantLock的特性和使用方法。

基本特点

  1. 可重入性

    • ReentrantLock支持可重入,即一个线程可以多次获取同一把锁而不会导致死锁。
    • 每次获取锁时,锁的计数器都会递增,只有当计数器回到0时,锁才会被释放。
  2. 公平性和非公平性

    • ReentrantLock支持公平性和非公平性两种模式。
    • 公平模式:按照线程请求锁的顺序来分配锁,可以避免某些线程长期得不到锁(线程饥饿)。
    • 非公平模式:默认模式,不保证获取锁的顺序,可能会出现线程饥饿现象,但通常具有更好的性能。
  3. 锁的可中断性

    • 线程在等待锁的过程中可以被中断。
    • 当线程等待锁时,可以响应InterruptedException,从而中断等待状态。
  4. 条件变量

    • ReentrantLock支持条件变量,可以用于实现更复杂的同步结构。
    • 通过newCondition()方法可以创建一个条件变量。
  5. 显式的锁定和解锁

    • 使用lock()方法获取锁,使用unlock()方法释放锁。
    • 由于需要手动释放锁,因此通常建议使用try-with-resources语句或finally块来确保锁的正确释放。

使用

  • lock() :加锁,如果获取不到锁就死等
  • trylock(超过时间):加锁,如果获取不到锁,等待一定时间之后就放弃加锁
  • unlock():解锁
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {

    private final ReentrantLock lock = new ReentrantLock();

    public void processResource() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + ": Processing resource.");
            // 执行关键操作
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        ReentrantLockExample example = new ReentrantLockExample();
        
        Thread thread1 = new Thread(() -> example.processResource(), "Thread 1");
        Thread thread2 = new Thread(() -> example.processResource(), "Thread 2");

        thread1.start();
        thread2.start();
    }
}

💊三.对比

下面是ReentrantLocksynchronized的对比,以表格形式呈现:

特性/机制ReentrantLocksynchronized
显式控制需要显式调用lock()unlock()方法自动获取和释放锁
异常处理需要显式处理异常,以确保锁正确释放自动释放锁,即使抛出异常
灵活性更高的灵活性,支持多种配置选项相对简单,自动处理锁
可重入性支持可重入支持可重入
公平性支持公平性和非公平性模式默认非公平锁
条件变量支持条件变量,通过newCondition()创建不支持条件变量
可中断性支持等待锁时的中断不支持等待锁时的中断
性能可以根据需要选择公平性或非公平性以优化性能默认非公平锁,通常提供较好的性能
锁的粒度可以显式指定锁对象对于实例方法,锁对象是该对象本身(即this)<br>对于静态方法,锁对象是类的Class对象<br>对于代码块,锁对象是显式指定的对象
使用场景需要更精细控制或支持条件变量时使用

需要简单易用或避免出错可能性时使用

实现方式是标准库中的一个类是一个关键字,是JVM内部实现的(大概率基于C++实现)

通过这个表格,您可以快速比较ReentrantLocksynchronized的不同特点和使用场景,以便在实际开发中做出合适的选择。

  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待⼀段时间就放弃
  • 更强⼤的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是⼀个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
  • 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
  • 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
  •   如果需要使用公平锁, 使用 ReentrantLock

🩸四.反思

人生不是一种享乐,而是一桩十分沉重的工作。--列夫·托尔斯泰

  1. 选择合适的同步机制

    • 在实际应用中,需要根据具体的应用场景和需求来选择合适的同步机制。
    • 如果需要更精细的控制和更复杂的同步结构,可以考虑使用ReentrantLock。
    • 如果追求简单易用和自动异常处理,可以选择使用synchronized。
  2. 性能考量

    • 在性能敏感的应用中,需要权衡公平性和性能之间的平衡。
    • 非公平锁通常提供更好的性能,但可能导致线程饥饿;公平锁可以避免线程饥饿,但可能会降低性能。
  3. 锁的粒度

    • 合理选择锁的粒度非常重要,细粒度锁可以提高并发性能,但可能会增加同步逻辑的复杂性。
    • 粗粒度锁简化了同步逻辑,但可能会降低并发性能。
  4. 避免死锁

    • 使用任何类型的锁时都需要小心,以避免死锁的发生。
    • 在设计同步逻辑时,需要确保锁的正确获取和释放顺序,避免潜在的死锁风险。
  5. 异常处理

    • 使用ReentrantLock时,需要格外注意异常处理,确保在任何情况下都能正确释放锁。
    • 在使用synchronized时,由于自动处理锁的释放,可以减少这方面的担忧。

通过这次学习,我对ReentrantLock和synchronized有了更深的理解,并认识到在设计并发程序时需要综合考虑多种因素。在实际开发中,合理地选择和使用这两种机制可以帮助我们编写更高效、更可靠的并发程序。未来,我将继续深入研究这些知识点,并努力将它们应用到实践中去。


🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀

以上,就是本期的全部内容啦,若有错误疏忽希望各位大佬及时指出💐

  制作不易,希望能对各位提供微小的帮助,可否留下你免费的赞呢🌸

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值