前言
在前面的多线程中,我们学习了为了解决线程不安全问题,使用 synchronized 为线程进行加锁,但是作为程序员光知道如何使用锁还不行,还需要知道有哪些锁策略。今天我将为大家分享在多线程中有哪些锁策略。
1. 乐观锁和悲观锁
悲观锁是一种基于悲观态度的锁机制,它假定最坏的情况,即在修改数据之前,它会先将数据锁住,阻止任何人对数据进行操作,直到它释放锁。这种锁的机制可以完全保证数据的独占性和正确性,因为每次请求都会先对数据进行加锁,然后进行数据操作,最后再解锁。然而,由于加锁和解锁的过程会造成消耗,所以这种策略的性能不高。
乐观锁则是一种基于乐观态度的锁机制,它假定不会发生数据冲突,只在提交操作的时候才锁定数据。这意味着,在数据提交之前,其他进程可以继续对数据进行操作。乐观锁可以实现并行操作,因此具有较高的性能。但需要注意的是,如果发生数据冲突,需要由数据库系统进行处理。
乐观锁和悲观锁都是计算机对后面发生事情的预测。悲观锁会觉得后面发生锁冲突的现象比较严重,所以在修改数据之前就会上锁;而乐观锁则觉得后面发生锁冲突的现象不严重,所以在处理数据之前就不会进行加锁。
给大家举个简单的例子:加入我们刚来到一个城市,周末的时候我想去体育馆打篮球,但是因为是刚来,不知道体育馆几点开门。那么这时候,悲观锁的做法就是:我现在家等着吧,问问朋友体育馆啥时候开门,但是朋友可能还在睡觉,所以我就只能在家等着;而乐观锁则是:”现在都8点了,体育馆应该开门了,我先过去,如果开门了就可以直接进去了,就算没开门,我也可以在体育馆外面等一会“。
2. 重量级锁和轻量级锁
重量级锁和轻量级锁是站在工作量的角度来划分的。
重量级锁基于操作系统的互斥量(Mutex Lock)而实现的锁,会导致进程在用户态和内核态之间切换,相对开销较大。例如,synchronized在内部基于监视器锁(monitor)实现,监视器锁基于底层的操作系统的Mutex Lock实现,因此在这种情况下synchronized属于重量级锁,重量级锁需要在用户态和核心态之间做转换。
- 大量的内核态用户态切换
- 很容易引发线程的调度
轻量级锁则是相对与重量级锁而言的,轻量级锁的核心设计实在没有多线程竞争的前提下,减少重量级锁的使用以提高系统性能。 轻量级锁适用于线程交替执行同步代码块的情况(既互斥操作),如果同一时刻与多个线程访问同一个锁,则将会导致轻量级锁膨胀为重量级锁。轻量级锁在发生线程竞争时,会让出 CPU 的执行权限,以便其他线程可以继续工作。
- 少量的内核态用户态切换.
- 不太容易引发线程调度
为什么重量级锁的用户态和内核态之间的转换效率会更低呢?
用户态的读写速率比内核态更慢。在内核态,CPU可以访问内存的所有数据,包括外围设备如硬盘、网卡等。同时,CPU也可以将自己从一个程序切换到另一个程序。而在用户态,程序只能受限地访问内存,且不允许访问外围设备。这种情况下,如果用户态的程序需要访问外围设备,如硬盘,那么它必须先切换到内核态,再由内核态进行系统调用来读写磁盘,这个过程会导致额外的开销。
为什么内核态操作与用户态的操作相比效率较低呢?
内核态的实现会占用内核稀缺的资源,例如操作系统需要维护线程列表,一旦操作系统装载后就无法动态改变。并且线程的数量远远大于进程的数量,随着线程数的增加,内核将耗尽,从而导致效率降低。此外,每次线程切换到内核态都需要陷入到内核,由操作系统来调度,这个过程也会花费一定的时间。
3. 自旋锁和挂起等待锁
自旋锁是一种轻量级锁,当一个线程尝试获取锁失败时,它会一直循环尝试获取锁,直到成功为止。这种机制消耗大量的CPU资源,因为它会使线程不断地尝试获取锁,无法做其他的工作。自旋锁只在加锁失败时进行忙等待,不会阻塞线程。
//这是一个自旋锁的伪代码 //当getLocker的返回结果为false的时候,表示未获取到锁,那么就继续循环 while(getLocker == false) { }
挂起等待锁是重量级锁的一种典型实现。当一个线程尝试获取锁失败时,它会通过内核的机制挂起等待,直到锁被释放。此时,线程会释放CPU资源,让其他线程可以执行。当锁被释放时,挂起的线程会重新尝试获取锁。挂起等待锁在等待期间会阻塞线程,导致线程无法做其他的工作。
//挂起等待锁,未获取到锁则是进入阻塞等待,等待内核态操作获取到锁 synchronized (locker) { }
自旋锁虽然会消耗 CPU 资源,但换来的是更快的响应速度。
这两种锁的选用取决于具体的应用场景和需求。如果线程间的交互非常频繁,且锁被持有的时间比较短,那么自旋锁可能更合适。如果线程间的交互比较少,且锁被持有的时间比较长,那么挂起等待锁可能更合适。
当线程想要获取锁但是这个锁又被别的线程获取到的时候,挂起等待锁会进入阻塞等待状态,因为进入阻塞的线程什么时候被唤醒是个不确定因素,它是由内核态操作决定的,所以就会导致程序的执行速度下降;而自旋锁则不会进入阻塞等待状态,而是不断循环判断这个锁时候还被占有,如果这个锁还被占有,那么自旋锁还会继续循环,直到这个锁被释放,当这个锁被释放的时候该线程就可以获取到该锁,保证了整个操作都处于用户态的操作。
4. 公平锁和非公平锁
公平锁和非公平锁是两种常用的线程同步机制,用于在多线程环境下保护共享资源。
公平锁是指多个线程按照请求锁的顺序获取锁,即先到先得的原则。在公平锁中,如果有多个线程等待获取锁,那么锁会依次分配给等待时间最长的线程,这样可以避免线程饥饿的情况。公平锁的实现比较复杂,需要维护一个线程等待队列,因此性能会比较低。
非公平锁是指多个线程按照竞争获取锁的顺序获取锁,即先到不一定先得的原则。在非公平锁中,如果有多个线程等待获取锁,那么锁可能会直接分配给等待时间较短的线程,这样可能会导致一些线程一直无法获取锁,出现线程饥饿的情况。非公平锁的实现比较简单,不需要维护一个线程等待队列,因此性能会比较高。
5. 可重入锁和非可重入锁
可重入锁是指同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提是锁对象得是同一个对象),不会因为之前已经获取过锁还没有释放而阻塞。可重入锁的一个优点是可以避免死锁。
非可重入锁则是相反,线程获取锁后,内部不能再获取锁,由于之前已经获取过还没释放而阻塞,可能会导致线程死锁。
6. 读写锁
我们通常对数据的操作就是读操作和写操作,如果是单线程的话,读和写操作不会发生问题,但是如果发生在多线程当中的话就会出现一些问题,当我一个线程在读数据的时候,另一个线程在写这个数据,那么到最后读取的数据就与数据本身不一样;当两个线程都进行写操作的时候,最终的结果也不是正确的结果,而多个线程同时进行读操作的时候是不会发生问题的。也就是说只有多个线程同时进行读操作的时候才不会发生线程安全的问题,那么该如何解决读操作和写操作在多线程中出现的问题呢?
读写锁,当多个线程同时进行读操作的时候不会发生互斥的问题,如果多个线程同时进行读操作和写操作或者同时进行写操作的时候,读写锁就是互斥的,后面的线程就无法获取到这个锁。
读写锁的特点是:
- 读读不互斥:多个线程可以同时读取共享资源,因为读操作本身是线程安全的。
- 读写互斥:当有一个线程正在写共享资源时,其他线程不能进行读或写操作,因为读写操作是互斥的,以防止数据不一致或数据竞争。
- 写写互斥:当有两个或多个线程同时写共享资源时,会产生互斥现象,以防止数据互相干扰。
Java synchronized 分别对应哪些锁策略
1. 乐观锁和悲观锁
对于乐观锁和悲观锁来说, synchronized 属于自适应锁。synchronized 一开始属于乐观锁,但是如果计算机预测到后面发生锁冲突的现象较严重的话,synchronized 就会转变为悲观锁。
2. 重量级锁和轻量级锁
对于重量级锁和轻量级锁来说,synchronized 也是属于自适应锁。开始由于线程的工作量较小,synchronized 是轻量级锁,但是如果到后面线程需要处理的工作量较大的话,synchronized 又会转变为重量级锁。
3. 自旋锁和挂起等待锁
对于自旋锁和挂起等待锁来说,synchronized 属于自适应锁。当锁冲突的现象不严重的时候,synchronized 为自旋锁,但是如果锁冲突现象较严重的话,synchronized 又会转换为挂起等待锁。
4. 公平锁和非公平锁
synchronized 属于非公平锁,当多个线程尝试获取同一把锁的时候,synchronized 不管你先来后到的顺序,而是所有等待的线程竞争这把锁。
5. 可重入锁和非可重入锁
synchronized 属于可重入锁,当一个线程在外面获取到这个锁的时候,在内部也会自动获取到这个锁,而不会陷入死锁的状态。
synchronized 不属于读写锁。
相关面试题
1) 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁。
乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突.
2) 介绍下读写锁?
- 读写锁就是把读操作和写操作分别进行加锁.
- 读锁和读锁之间不互斥.
- 写锁和写锁之间互斥.
- 写锁和读锁之间互斥.
- 读写锁最主要用在 “频繁读, 不频繁写” 的场景中.
3) 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
相比于挂起等待锁,
优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源
4) synchronized 是可重入锁么?
是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁.
实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增.