前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Java并发——并发中的锁(五)

Java并发——并发中的锁(五)

原创
作者头像
翰墨飘香
修改2024-04-25 08:10:06
330
修改2024-04-25 08:10:06
举报
文章被收录于专栏:Java并发Java并发

一、Java中锁分类

1.1 偏向锁/轻量级锁/重量级锁

这三种锁指的是synchronized锁的状态,Java1.6之前是基于重量级锁,Java1.6之后对synchronized进行了优化,为了减少获取和释放锁带来的性能消耗,引入了偏向锁、轻量级锁以及锁的升级机制。锁升级的路径:无锁→偏向锁→轻量级锁→重量级锁。

1、偏向锁(Biased Locking)

为了提高性能,锁会记住最后一个获得锁的线程,并在该线程再次请求锁时直接赋予它。这种锁适用于锁竞争激烈但偏向于同一线程的场景。

如果自始至终,对于这把锁都不存在竞争,那么其实就没必要上锁,只需要打个标记就行了,这就是偏向锁的思想。一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的,当有第一个线程来访问它并尝试获取锁的时候,它就将这个线程记录下来,以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,开销很小,性能最好。

https://www.cnblogs.com/javaminer/p/3892288.html https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf

从 JDK 15 开始,偏向锁特性被官方标记为废弃状态

你知道 Java 的偏向锁要被废弃掉了吗? Deprecate and Disable Biased Locking

2、轻量级锁(Lightweight Locking)

也称为自旋锁的一种,适用于短时间内等待锁的场景。轻量级锁的开销较小,但可能导致忙等待(即线程不断循环检查锁是否可用)。

JVM 开发者发现在很多情况下,synchronized 中的代码是被多个线程交替执行的,而不是同时执行的,也就是说并不存在实际的竞争,或者是只有短时间的锁竞争,用 CAS 就可以解决,这种情况下,用完全互斥的重量级锁是没必要的。轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞。

3、重量级锁(Heavyweight Locking)

当线程尝试获取锁失败时,会进入阻塞状态,等待操作系统调度。重量级锁的开销较大,但可以避免忙等待。

重量级锁是互斥锁,它是利用操作系统的同步机制实现的,所以开销相对比较大。当多个线程直接有实际竞争,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。

1.2 可重入锁/非可重入锁

在同一个线程中,外层方法获取锁之后,在进入内层方法时会自动获取锁则为可重入锁,进入内层方法时需要重新获取锁的为不可重入锁。可重入锁的一个好处是可一定程度避免死锁。

ReentrantLock和synchronized 都是可重入的

1.3 共享锁/独占锁

1、独占锁

独占锁也叫排他锁、互斥锁、独享锁,是指锁在同一时刻只能被一个线程所持有。一个线程加锁后,任何其他试图再次加锁的线程都会被阻塞,直到持有锁线程解锁。通俗来说,就是共享资源某一时刻只能有一个线程访问,其余线程阻塞等待。

如果是公平地独占锁,在持有锁线程解锁时,如果有一个以上的线程在阻塞等待,那么最先抢锁的线程被唤醒变为就绪状态去执行加锁操作,其他的线程仍然阻塞等待。

java中的Synchronized内置锁和ReentrantLock显式锁都是独占锁。

2、共享锁

共享锁就是在同一时刻允许多个线程持有的锁。当然,获得共享锁的线程只能读取临界区的数据,不能修改临界区的数据。

JUC中的共享锁包括Semaphore(信号量)、ReadLock(读写锁)中的读锁、CountDownLatch倒数闩。

1.4 公平锁/非公平锁

1、公平锁

公平锁指的是按照线程请求的顺序,来分配锁

如果线程现在拿不到这把锁,那么线程就都会进入等待,开始排队,在等待队列里等待时间长的线程会优先拿到这把锁,类似于排队打饭,先来后到。在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照FIFO(先进先出)的规则从队列中取到自己。公平锁的优点在于所有的线程都能得到资源,不会饿死在队列中。然而,其缺点在于吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,CPU唤醒阻塞线程的开销会很大。

2、非公平锁

非公平锁则是指多个线程在获取锁时并不严格按照申请锁的顺序来,有可能后申请的线程比先申请的线程优先获取锁。非公平锁在加锁时不考虑排队等待情况,直接尝试获取锁,如果获取不到才会进入等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁。非公平锁的优点在于其吞吐量比公平锁大,能更充分地利用CPU的时间片,尽量减少CPU空闲的状态时间。然而,其缺点在于可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,从而导致饿死或者优先级翻转的问题。

3、例子

在Java的并发包中,ReentrantLock的创建可以指定构造函数的boolean类型来得到公平锁或非公平锁,默认是非公平锁。需要注意的是,虽然非公平锁在性能上可能优于公平锁,但在某些场景下,公平锁可能更为合适,因为它可以确保所有线程都能公平地获取到资源,避免某些线程长时间得不到执行。

1.5 悲观锁/乐观锁

悲观锁和乐观锁是两种在并发控制中常用的策略,它们各自有不同的特点和适用场景。

1、悲观锁

持悲观态度的策略,假设资源在大多数情况下都会被其他线程修改,因此在处理过程中会将资源加锁。一旦资源被某个线程锁定,其他线程就不能对其进行修改,直到锁被释放。这种策略能确保资源的一致性和完整性,但可能会降低系统的并发性能,因为多个线程在等待锁释放的过程中会发生阻塞。悲观锁通常应用于数据库中的行锁、表锁、读锁和写锁等,以及使用synchronized关键字实现的锁。

2、乐观锁

乐观锁持乐观态度。它假设资源在一般情况下不会发生冲突,因此不会在数据处理开始时立即加锁。而是在数据提交更新时,才会检查数据是否在此期间被其他线程修改过。如果数据已被其他事务修改,则当前事务会采取相应的措施,如重新读取数据并尝试更新,或者放弃操作并返回错误信息给用户。乐观锁可以提高系统的并发性能,但可能会增加额外的开销,例如循环检查和重试更新操作。乐观锁通常通过用户自己实现的锁机制来实现

3、例子

悲观锁:synchronized和Lock

处理资源之前一定要拿到锁,处理完再解开锁,典型的悲观锁

乐观锁:原子类

大喜大悲:数据库

4、对比

锁类型

说明

优势

劣势

适用场景

悲观锁

悲观,先加锁再操作

能确保资源的一致性和完整性

可能会降低系统的并发性能

数据冲突的可能性较大,或者对数据一致性要求高,并发写入多、临界区代码复杂、竞争激烈等场景

乐观锁

数据提交更新是,检查是否被修改过

开销小

如果一直拿不到锁,或者并发量大,竞争激烈,导致不停重试,那么消耗的资源也会越来越多,甚至开销会超过悲观锁

数据冲突的可能性较小,或者希望提高系统的并发性能,适用于大部分是读取,少部分是修改的场景,也适合虽然读写都很多,但是并发并不激烈的场景

1.6 自旋锁/非自旋锁

自旋锁的理念是如果线程现在拿不到锁,并不直接陷入阻塞或者释放 CPU 资源,而是开始利用循环,不停地尝试获取锁,这个循环过程被形象地比喻为“自旋”,就像是线程在“自我旋转”。相反,非自旋锁的理念就是没有自旋的过程,如果拿不到锁就直接放弃,或者进行其他的处理逻辑,例如去排队、陷入阻塞等。

自旋锁是一种特殊的锁机制,当某个线程尝试获取已经被其他线程持有的锁时,它不会立即进入阻塞状态,而是会“自旋”,即在原地循环等待,不断检查锁是否可用。这种锁机制适用于那些持有锁的时间较短的场景,因为线程在等待期间只是简单地执行循环,不会造成上下文切换的开销。然而,如果锁被持有的时间较长,自旋锁会导致线程长时间处于忙等状态,浪费CPU资源。

相反,非自旋锁如果拿不到锁就直接放弃,或者进行其他的处理逻辑,例如去排队、陷入阻塞等 。在获取锁失败时会使线程进入阻塞状态,并等待锁释放后再进行唤醒的这种机制适用于锁持有时间较长的场景,因为线程在等待期间可以释放CPU资源,进行其他任务。然而,线程的阻塞和唤醒操作本身需要一定的开销,特别是在锁持有时间较短的情况下,这种开销可能会超过线程实际等待的时间。

下面我们用这样一张流程图来对比一下自旋锁和非自旋锁的获取锁的过程。

1.7 可中断锁/不可中断锁

可中断锁与不可中断锁是一组线程尝试挂起失败(由于中断)后是否继续获锁的策略。

可中断锁是指线程在尝试挂起失败后,响应并抛出中断异常的策略。这种策略将导致线程停止获锁,并有机会处理其他任务或逻辑,如去排队、陷入阻塞等。当线程在等待获取锁的过程中,如果接收到中断信号,它可以选择放弃等待并抛出中断异常,从而避免长时间无效等待。

而不可中断锁是指线程在尝试挂起失败后,不抛出中断异常,而是继续尝试获锁的策略。这种锁在线程获得锁后,其他线程想要获得锁,必须处于阻塞或等待状态。如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断。

这两种锁的选择取决于具体的应用场景和需求。可中断锁提供了更大的灵活性,允许线程在等待锁的过程中响应中断信号,从而避免可能的死锁或长时间等待。而不可中断锁则更适用于那些需要确保线程持续等待直到获得锁的场景。

二、synchronized锁

详细见Java并发——synchronized锁

三、Lock锁

Java并发——Lock锁

四、synchronized 和 Lock 对比

相同点:

1、synchronized 和 Lock 都是用来保护资源线程安全的

2、都可以保证可见性

3、synchronized 和 ReentrantLock 都是可重入的

不同点:

1、实现方式不同

Synchronized是Java语言内置的关键字,基于JVM实现;ReentrantLock是基于Java的Lock接口实现的,提供了更灵活的同步机制

2、锁的获取与释放

synchronized在获取和释放锁时是隐式的,而ReentrantLock则需要显式地调用lock()方法获取锁,以及在finally块中调用unlock()方法释放锁。这意味着,如果在使用ReentrantLock时忘记释放锁,可能会导致死锁。

3、锁的公平性

synchronized是非公平锁,而ReentrantLock支持并可指定非公平锁与公平锁,默认是非公平锁。

4、异常处理

当发生异常时,synchronized会自动释放占有的锁,而ReentrantLock在发生异常时不会主动释放锁,必须手动调用unlock()方法来释放,否则可能引起死锁。

5、等待可中断性

ReentrantLock的等待可中断,即在等待获取锁的过程中,线程可以响应中断,而synchronized则不能。

6、性能不同

在低并发情况下,Synchronized的性能通常优于ReentrantLock;在高并发情况下,ReentrantLock可能表现更好

7、锁的获取状态查询

ReentrantLock可以通过tryLock()方法来查询是否获取到锁,而synchronized无法做到。

8、超时获取锁

ReentrantLock可以通过tryLock(long time, TimeUnit unit)方法在指定的截止时间之前获取锁

五、JVM的锁优化

5.1 自适应的自旋锁

首先,我们来看一下自适应的自旋锁。先来复习一下自旋的概念和自旋的缺点。“自旋”就是不释放 CPU,一直循环尝试获取锁,如下面这段代码所

代码语言:java
复制
public final long getAndAddLong(Object var1, long var2, long var4) {
    long var6;
    do {
        var6 = this.getLongVolatile(var1, var2);
    } while(!this.compareAndSwapLong(var1, var2, var6, var6 + var4));
    return var6;
}

代码中使用一个 do-while 循环来一直尝试修改 long 的值。自旋的缺点在于如果自旋时间过长,那么性能开销是很大的,浪费了 CPU 资源。

在 JDK 1.6 中引入了自适应的自旋锁来解决长时间自旋的问题。自适应意味着自旋的时间不再固定,而是会根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定。自旋的持续时间是变化的,自旋锁变“聪明”了。比如,如果最近尝试自旋获取某一把锁成功了,那么下一次可能还会继续使用自旋,并且允许自旋更长的时间;但是如果最近自旋获取某一把锁失败了,那么可能会省略掉自旋的过程,以便减少无用的自旋,提高效率。

5.2 锁消除

经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉

例如,我们的 StringBuffer 的 append 方法如下所示:

代码语言:java
复制
@Override
public synchronized StringBuffer append(Object obj) {
    toStringCache = null;
    super.append(String.valueOf(obj));
    return this;
}

从代码中可以看出,这个方法是被 synchronized 修饰的同步方法,因为它可能会被多个线程同时使用。

但是在大多数情况下,它只会在一个线程内被使用,如果编译器能确定这个 StringBuffer 对象只会在一个线程内被使用,就代表肯定是线程安全的,那么我们的编译器便会做出优化,把对应的 synchronized 给消除,省去加锁和解锁的操作,以便增加整体的效率

5.3 锁粗化

锁粗化是指,将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

锁粗化功能是默认打开的,用 -XX:-EliminateLocks 可以关闭该功能。

Demo1

比如我对for循环里面的代码块进行了加锁操作,JVM为了提升效率,会直接把锁放到for循环外面,对整个for循环的代码块进行加锁操作

不过,我们这样做也有一个副作用,那就是我们会让同步区域变大。

代码语言:Java
复制
for (int i = 0; i < 1000; i++) {
    synchronized (this) {
        //do something
    }
}

Demo2

如果我们释放了锁,紧接着什么都没做,又重新获取锁,例如下面这段代码所示:

代码语言:java
复制
public void lockCoarsening() {
    synchronized (this) {
       //do something
    }
    synchronized (this) {
        //do something
    }
    synchronized (this) {
        //do something

    }
}

那么其实这种释放和重新获取锁是完全没有必要的,如果我们把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁,那么就可以把中间这些无意义的解锁和加锁的过程消除,相当于是把几个 synchronized 块合并为一个较大的同步块。这样做的好处在于在线程执行这些代码时,就无须频繁申请与释放锁了,这样就减少了性能开销。

5.4 偏向锁/轻量级锁/重量级锁

详细见1.1 偏向锁/轻量级锁/重量级锁

我正在参与2024腾讯技术创作特训营最新征文,快来和我瓜分大奖!

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、Java中锁分类
    • 1.1 偏向锁/轻量级锁/重量级锁
      • 1、偏向锁(Biased Locking)
      • 2、轻量级锁(Lightweight Locking)
      • 3、重量级锁(Heavyweight Locking)
    • 1.2 可重入锁/非可重入锁
      • 1.3 共享锁/独占锁
        • 1、独占锁
        • 2、共享锁
      • 1.4 公平锁/非公平锁
        • 1、公平锁
        • 2、非公平锁
        • 3、例子
      • 1.5 悲观锁/乐观锁
        • 1、悲观锁
        • 2、乐观锁
        • 3、例子
        • 4、对比
      • 1.6 自旋锁/非自旋锁
        • 1.7 可中断锁/不可中断锁
        • 二、synchronized锁
        • 三、Lock锁
        • 四、synchronized 和 Lock 对比
          • 相同点:
            • 不同点:
            • 五、JVM的锁优化
              • 5.1 自适应的自旋锁
                • 5.2 锁消除
                  • 5.3 锁粗化
                    • 5.4 偏向锁/轻量级锁/重量级锁
                    领券
                    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档