并发编程 - synchronized 详解

并发编程 synchronized

一、前言

前几天我们提到,JMM 封装后的表现形式之一,就是 synchronized 关键字了(其他的还有 lock happens-before)

今天,我们就来细品一下 synchronized

对于 synchronized 如何使用和基础的类锁,对象锁,我这里就不再介绍了,如有不懂的小伙伴,可以上网查找相关资料,并写几个 demo 看看

二、Synchronized 详解

1、synchronized 实现

这里,我们对这么一段 java 代码进行反编译:

public class Synchronized {
    public static void main(String[] args) {
        Object lock = new Object();
        int count = 0;
        synchronized (lock) {
            count++;
        }
    }
}
# 编译成字节码文件
javac Synchronized.java

# 反编译,查看 .class 的信息
javap -verbose Synchronized.class

结果如下:

我们主要关注 monitorentermonitorexit

monitor 译为管程,其是 OS 中信号量和 mutex 锁的进一步封装

image-20210917213317654

MonitorenterMonitorexit指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待**(争抢到锁)**

  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加**(重入锁)**

  • 这把锁已经被别的线程获取了,等待锁释放**(锁已经被别人拥有,自己继续等待)**

管程原理图

2、synchronized 和 happens-before 规则及对可见性,原子性的保障

在昨天的分享中,我们提到了 8个 happens-before 规则

其中一条,就是 对同一个监视器的解锁,happens-before于对该监视器的加锁,咱们的 synchronized 对应的就是这一条

public class MonitorDemo {
    private int a = 0;

    public synchronized void writer() {     // 1
        a++;                                // 2
    }                                       // 3

    public synchronized void reader() {    // 4
        int i = a;                         // 5
    }                                      // 6
}

当在并发情况下分别调用这两个方法的时候,因为加了方法锁,所以其执行流程是这样的:

image-20210917215216115

线程 B 在获得锁之前,必须要等线程 A 释放锁,当 A 释放锁之后,线程 A 对临界区的操作,已经由本地内存刷新到主存中了,从而保证了可见性

又因为 monitor 的不可抢占,线程 B 无法在线程 A 释放锁之前,提前获得锁,以这种方式,保证线程A 的操作一定完成,从而又保障了原子性

3、synchronized 锁优化

Synchronized 依赖管程(monitor)实现,而管程是高级语言的特性(c这种偏底层的语言是没有的),其实现方式,依赖的是 操作系统的 mutex 锁。However,要使用 mutex 锁,需要将当前线程挂起,并从用户态写环岛内核态来运行,这种方式代价是十分大的(这也是为什么在 synchronized 优化之前,其效率不及 ReentrantLock)

然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销

在Java SE 1.6里Synchronied同步锁,一共有四种状态:无锁偏向锁轻量级所重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提高获取锁和释放锁的效率。

锁升级方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

不同类型锁简介
  • 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。

  • 锁消除(Lock Elimination):通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。

  • 偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。

  • 轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。

  • 适应性自旋(Adaptive Spinning):当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。

1 - 自旋锁和自适应自旋
  • 自旋锁:

所谓自旋,是让尝试获取锁,但获取不到的线程,先不要进度等待,而是在占用 CPU 时间片的情况下等待锁的释放,这样,就避免了 保存线程状态,用户态陷入内核态这样耗时的操作(因为大部分情况下,锁被获取之后,很快就会被释放)

我举个例子,假如你下课了想去上厕所,当你发现厕所有人了,你就想着先回趟教室,过一会儿再回来看看有没有空,那这就是传统的挂起操作(要用户态转内核态),相对耗时;而如果你决定就站在厕所门口等那人出来了,那个人一出来,你就可以进厕所,从而节约了比较多的时间,这,就是自旋锁

这里要注意,如果锁占用的时间非常的短,那么自旋锁的性能会非常的好,如果锁占用的时间比较长,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自选超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次

可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)

  • 自适应自旋:

在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。

如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环(吃定了这个锁)。(认定占着厕所的人马上出来,那我们就死死地站在,门口不走了)

相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。(之前有兄弟在等这个坑的时候,等了好久都没等到,只能回教室了,我们知道后,就认为那人掉坑里了,自然对这个坑失去希望了,会直接跳过它)

有了自适应自旋,JVM 会更加聪明,不会每个锁都傻等10次自旋,而是对可能性高的锁,吃定他(有的锁可能要等11次,如果等了10次不耐烦,转重量级锁了,就得不偿失);对可能性低的锁,忽视它(省下 CPU 资源)

2 - 锁消除

JVM会判断再一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据时线程独有的,不需要加同步。此时就会进行锁消除

我们自己编程的时候,很清楚的知道那些地方时线程独有的,不需要加同步锁,但是在Java API中有很多方法都是加了同步的,那么此时JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。

在操作String类型数据时,由于String是一个不可变类,对字符串的连接操作总是通过生成的新的String对象来进行的。因此Javac编译器会对String连接做自动优化。在JDK 1.5之前会使用StringBuffer对象的连续append()操作,在JDK 1.5及以后的版本中,会转化为StringBuidler对象的连续append()操作。

public static String test03(String s1, String s2, String s3) {
    String s = s1 + s2 + s3;
    return s;
}
3 - 锁粗化

如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。

4 - 偏向锁

是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。

5 - 轻量级锁

在JDK 1.6之后引入的轻量级锁,需要注意的是轻量级锁并不是替代重量级锁的,而是对在大多数情况下同步块并不会有竞争出现提出的一种优化。它可以减少重量级锁对线程的阻塞带来地线程开销。从而提高并发性能。

4、Synchronized 和 Lock

1)Synchronized 缺陷
  • 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时
  • 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
  • 无法知道是否成功获得锁,相对而言,Lock可以拿到状态,如果成功获取锁,…,如果获取失败,…
2)Lock 对应解决的问题

多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了。

5、使用 Synchronized 的注意点

synchronized是通过软件(JVM)实现的,简单易用,即使在JDK5之后有了Lock,仍然被广泛地使用。

  • 使用Synchronized有哪些要注意的?
    • 锁对象不能为空,因为锁的信息都保存在对象头里
    • 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
    • 避免死锁
    • 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错

三、小结

synchronized 的相关知识分享就是这么多

明天,将会带来 Volatile 和 final 的相关知识

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FARO_Z

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值