关于锁

Java中的同步锁synchronized

synchronized的基本用法

根据修饰的对象

  • 修饰实例方法:给当前实例加锁。
  • 静态方法:给当前类对象加锁。
  • 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码前要获得给定对象的锁。

给当前实例加锁时,创建多个实例的时候,使用的是不同的锁。给当前类对象加锁时,即使创建多个实例对象,使用的还是同一把锁,线程之间保证同步关系。

锁的底层实现

锁的实现位置

image-20200923132302834

可以看到,锁的实现依赖对象头中的monitor机制。

monitor机制

首先查看一个简单的加锁程序的字节码:

public static void main(String[] args) {
    synchronized (SynchronizedDemo.class) {
    }
    method();
}

private static void method() {
}

image-20200923130927687

其中标红的monitorenter指令表示去获得一个对象监视器。monitorexit指令表示释放monitor监视器的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器。当线程获取到monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。monitor依赖操作系统的MutexLock(互斥锁)来实现的,线程被阻塞后便进入内核(Linux)调度状态,会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。

而当一个线程获取到了这个监视器,又一次执行同步代码时,就不必再执行一次monitorenter了(即第12行标红处,只有monitorexit而没有monitorenter)。这是因为锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一

下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:

image-20200923134211785

任意线程对Object(Object由synchronized保护)的访问,首先要获得Object的监视器。如果获取失败,线程进入同步队列,线程状态变为BLOCKED。当访问Object的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。

锁的升级

同一时刻只有一个线程能够获得对象的监视器(monitor),即表现为互斥性,实际是一种悲观锁策略,效率不高,所以需要对锁进行优化。CAS机制就是对锁的优化。

CAS机制

CAS(compare and swap):比较并替换。

比较替换的过程需要三个值:内存地址V,旧的预期值O,即将要更新的目标值N。

当线程1修改内存中的同步对象V时,对线程1来说,会产生修改前的旧值O和修改后的新值N(此时旧值O和V相同)。然后线程1会比较旧值O和内存中的值V,如果O和V的值相同,则更新V的值为N。而在线程1比较O和V之前,线程2抢先修改了内存V的值,这时线程1比较发现旧值O和内存V的值不相同,提交失败。

这其实是一种乐观锁策略,这种策略下线程没有阻塞停顿,如果线程出现冲突会重试CAS而不是阻塞线程。

CAS的问题

1、ABA问题

即A->B->A问题:一个旧值A变为了成B,然后再变成A,此时CAS时发现旧值依然为A,并没有变化,但是实际上是发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。在java 1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题。

2、自旋带来的性能开销

使用CAS时,线程不会挂起而是自旋重试,此时带来的cpu性能消耗肯定高于挂起这个线程。这个问题使得程序不能一直使用CAS来代替线程的阻塞和唤醒,当并发量过大时,反而阻塞唤醒的操作更快。

3. 只能保证一个共享变量的原子操作

即当一个线程对多个共享变量进行CAS时,此时无法保证这多个对象的原子性。有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。

对象头中的锁信息

image-20200923123743373

锁可以按偏向锁->轻量级锁->重量级锁的顺序对锁进行升级,非必要不升级,按顺序其性能开销才会越来越大。锁可以升级但不能降级

偏向锁

大多数情况下,锁不仅仅不存在多线程的竞争,而且总是由同一个线程多次获得。在这个背景下设计了偏向锁。

偏向锁通过线程ID来判断线程冲突。当一个线程访问同步块并获取锁时,会在对象头栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径(偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能)。

轻量级锁

如果偏向锁被关闭或者当前偏向锁已经已经被其他线程获取,这时如果有线程去抢占同步锁时,锁会升级到轻量级锁。此时线程会通过多次cas(10次后锁会膨胀为重量级锁)来抢占锁,线程同样没有阻塞和唤醒的操作。

重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时对象的标记Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态,即会有线程的阻塞和唤醒操作。

多个线程竞争同一个锁的时候,虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。

Java 线程的阻塞以及唤醒依靠操作系统来完成的:os pthread_mutex_lock() ;

线程的通信(wait/notify)

wait阻塞线程,且会释放锁,notify唤醒线程,且抢占锁。

在Java中提供了wait/notify这个机制,用来实现条件等待和唤醒。比如以抢占锁为例,假设线程A持有锁,线程B再去抢占锁时,它需要等待持有锁的线程释放之后才能抢占,那线程B怎么知道线程A什么时候释放呢?这个时候就可以采用通信机制。

死锁问题

一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象

死锁发生的条件

  • 互斥,共享资源 X 和 Y 只能被一个线程占用;
  • 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  • 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  • 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

如何避免死锁

按照前面说的四个死锁的发生条件,我们只需要破坏其中一个,就可以避免死锁的产生。其中,互斥这个条件没有办法破坏,因为用的就是互斥锁,其他三个条件都有办法可以破坏。

  • 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。

  • 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。

  • 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。


参考:

并发编程

《java并发编程的艺术》

文章已创建 17

相关文章

开始在上面输入您的搜索词,然后按回车进行搜索。按ESC取消。

返回顶部