多线程并发 — 你应该了解的线程锁

回顾操作系统的发展历史:手工操作(串行执行程序) —> 批处理系统(自动地、成批地处理一个或多个用户的作业) —> 多道程序设计技术(允许多个程序同时进入内存并交替在CPU中运行) —> 分时系统(采用时间片轮转的方式同时为几个、几十个甚至几百个用户服务) —> 实时系统 —> ...,从操作系统的发展可以看出来,从单任务到多任务,从多道处理到分时处理,计算机的资源利用率和并发性越来越高了。

为了提高处理器资源的利用率提高系统的吞吐率,基本上都采用多线程和并发的运作方式。
并发(Concurrency):是指在在同一时间间隔(即某个时间段内),多任务交替处理的能力(理想情况下的同时)。CPU把可执行时间均匀地分成若干份,每个进程执行一段时间后,记录当前的工作状态,释放相关的执行资源并进入等待状态,让其他线程抢占CPU资源。
并行(Parallelism):指在同一时刻,有多条指令在多个处理器上同时执行,也就是真正意义上的同时。

image.png

1. 多线程开发为什么要用锁?

锁-是为了解决多线程并发操作引起的脏读、数据不一致的问题。

疑问:
那么为什么多线程并发操作会引起脏读、数据不一致的问题?

  1. Java 内存模型规定了所有的变量都存储在主内存中,每条线程有自己的工作内存。
  2. 线程的工作内存中保存了该线程中用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不是直接读写主内存。
  3. 线程访问一个变量,首先将变量从主内存拷贝到工作内存,对变量的写操作,不会马上同步到主内存。
  4. 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

2. 线程锁的分类

  • 2.1、公平锁与非公平锁

    • 公平锁:
      是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来获得锁,不允许其他线程插队获得锁。
    • 非公平锁:
      是允许插队获得锁。

    优缺点:
    非公平锁性能高于公平锁性能,非公平锁能更充分的利用cpu的时间片,尽量的减少cpu空闲的状态时间。但是使用非公平锁有些线程可能会饿死或者说很早就在等待锁,但要等很久才会获得锁,而使用公平锁等待锁的线程不会饿死。
    具体表现形式:
    ReentrantLock可以指定是公平锁还是非公平锁。
    而synchronized只能是非公平锁。

  • 2.2、可重入锁与不可重入锁

    • 可重入锁:
      指的是可重复可递归调用的锁,在外层函数获得锁之后,在内层递归函数仍然再次获得之前已经获得的锁,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。
      好处:
      一定程度上避免产生死锁。
    • 不可重入锁:
      和可重入锁相反,指的是同一线程外层函数获得锁之后,那么在内层递归函数不能再次获取该锁而被阻塞。

    概念区分:当一个线程获得当前实例的锁lock,并且进入了方法A,该线程在方法A没有释放该锁的时候,是否可以再次进入使用该锁的方法B?
    不可重入锁:在方法A释放锁之前,不可以再次进入方法B
    可重入锁:在方法A释放该锁之前可以再次进入方法B。
    具体表现形式:
    ReentrantLock 和synchronized 都是可重入锁。

  • 2.3、自旋锁与阻塞锁

    • 自旋锁:
      当一条线程需要请求一把已经被占用的锁时,并不会进入阻塞状态,而是继续持有CPU执行权等待一段时间,去执行一次空循环,循环结束后再重新去竞争锁,如果在自旋完成后前面锁同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免了切换线程的开销,如果竞争不到则继续循环。

      • 背景:
        互斥同步对性能最大的影响是阻塞,挂起和恢复线程都需要转入内核态中完成,虚拟机为了避免线程真实的在操作系统层面上被挂起,这个时候可以利用自旋锁的优化手段。通常情况下,线程持有锁的时间都不会太长也就是共享数据的锁定状态只持续很短的一段时间,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间转换需要从用户态转化为核心态,这个状态之间的转化相对比较耗时,因此自旋锁会假设在不久的将来当前的线程就可以获得锁,因此虚拟机会让当前想要获取的线程做几个空的循环,一般不会太久,在经过一段时间的循环之后再重新去竞争锁,如果获得了锁,就可以进入到了临界区。如果还不能获取到锁,那么就会将线程在操作系统层面上挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的,最后没有办法就只能升级为重量级锁。
      • 优点:
        由于自旋等待锁的过程线程并不会引起上下文切换(用户态转向核心态),因此比较高效;
      • 缺点:
        自旋等待过程线程一直占用CPU执行权但不处理任何任务,因此若该过程过长,那就会造成CPU资源的浪费。
      • 自适应自旋:
        自适应自旋可以根据以往自旋等待时间的经验,计算出一个较为合理的本次自旋等待时间。
    • 阻塞锁:
      和自旋锁相对,指当线程获取锁失败时,线程进入阻塞(blocking)状态,当获取相应的信号时(唤醒,时间),进入线程的准备就绪状态,准备就绪状态的所有线程,通过竞争,进入运行状态。

    适用情况:
    自旋等待不能代替阻塞。自旋等待虽然不处理任何任务,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋效果就会非常好,线程不会进行上下文切换(用户态转向核心态),反之,如果锁被占用的时间很长,那么自旋的线程只会造成CPU资源的浪费。

  • 2.4、乐观锁与悲观锁

    • 乐观锁:
      乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新发生冲突,那么就应该有相应的重试逻辑。
      具体表现形式:java的原子类的递增操作
      原理:采用CAS算法

    • 悲观锁:
      悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改,是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。
      具体表现形式:synchronized关键字和lock实现类
      在效率方面,处理加锁的机制会产生额外的开销,还有增加产生死锁的机会,同时也会降低并发性能。

    使用场景:
    悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
    乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

  • 2.5、互斥锁与共享锁

    • 互斥锁:
      同时只能有一个线程获得锁。
      具体表现形式:
      ReentrantLock 是互斥锁,ReadWriteLock 中的写锁是互斥锁。

    • 共享锁:
      可以有多个线程同时获得锁。
      具体表现形式:
      Semaphore、CountDownLatch 是共享锁,ReadWriteLock 中的读锁是共享锁。

  • 2.6、偏向锁、轻量级锁及重量级锁

    • 偏向锁
      偏向锁是为了消除无竞争情况下的同步原语,进一步提升程序性能。
      与轻量级锁的区别:
      轻量级锁是在无竞争的情况下使用CAS操作来代替互斥量的使用,从而实现同步;而偏向锁是在无竞争的情况下完全取消同步。
      与轻量级锁的相同点:
      它们都是乐观锁,都认为同步期间不会有其他线程竞争锁。
      原理:
      当线程请求到锁对象后,将锁对象的状态标志位改为01,即偏向模式。然后使用CAS操作将线程的ID记录在锁对象的Mark Word中。以后该线程可以直接进入同步块,连CAS操作都不需要。但是,一旦有第二条线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。
      优点:
      偏向锁可以提高有同步但没有竞争的程序性能。但是如果锁对象时常被多条线程竞争,那偏向锁就是多余的。
      偏向锁可以通过虚拟机的参数来控制它是否开启。

    • 轻量级锁
      本质:
      使用CAS取代互斥同步。
      轻量级锁与重量级锁的比较:
      重量级锁是一种悲观锁,它认为总是有多条线程要竞争锁,所以它每次处理共享数据时,不管当前系统中是否真的有线程在竞争锁,它都会使用互斥同步来保证线程的安全;
      而轻量级锁是一种乐观锁,它认为锁存在竞争的概率比较小,所以它不使用互斥同步,而是使用CAS操作来获得锁,这样能减少互斥同步所使用的互斥量带来的性能开销,但是如果始终得不到锁竞争的线程使用自旋会消耗CPU。
      实现原理:

      1. 对象头称为Mark Word,虚拟机为了节约对象的存储空间,对象处于不同的状态下,Mark Word中存储的信息也所有不同。
      2. Mark Word中有个标志位用来表示当前对象所处的状态。
      3. 当线程请求锁时,若该锁对象的Mark Word中标志位为01(未锁定状态),则在该线程的栈帧中创建一块名为锁记录的空间,然后将锁对象的Mark Word拷贝至该空间;最后通过CAS操作将锁对象的Mark Word指向该锁记录;
      4. 若CAS操作成功,则轻量级锁的上锁过程成功;
      5. 若CAS操作失败,再判断当前线程是否已经持有了该轻量级锁;若已经持有,则直接进入同步块;若尚未持有,则表示该锁已经被其他线程占用,此时轻量级锁就要膨胀成重量级锁。

      注意:轻量级锁比重量级锁性能更高的前提是,在轻量级锁被占用的整个同步周期内,不存在其他线程的竞争。若在该过程中一旦有其他线程竞争,那么就会膨胀成重量级锁,从而除了使用互斥量以外,还额外发生了CAS操作,因此更慢!

    • 重量级锁
      在JVM中又叫对象监视器(Monitor),它还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后者用于做线程同步。
      整个synchronized锁流程如下:

      1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
      2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
      3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
      4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
      5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
      6. 如果自旋成功则依然处于轻量级状态。
      7. 如果自旋失败,则升级为重量级锁。