一、锁
锁其实是操作系统中的一个概念。在多线程编程中,操作系统引入了锁机制。通过锁机制,能够保证在多核多线程环境中,在某一个时间点上,只能有一个线程进入临界区代码,从而保证临界区中操作数据的一致性。
所谓的锁,可以理解为内存中的一个整型数,拥有两种状态:空闲状态和上锁状态。加锁时,判断锁是否空闲,如果空闲,修改为上锁状态,返回成功;如果已经上锁,则返回失败。解锁时,则把锁状态修改为空闲状态。
二、Java中的八锁
1、乐观锁与悲观锁
(1)乐观锁和悲观锁其实是在数据库中引入的名词,但在Java并发编程中也体现了这样的思想。
悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加排它锁,并在整个数据处理过程中,使数据处于锁定状态。
乐观锁相对悲观锁来说的,认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而在进行数据提交更新时,才会正式对数据冲突与否进行检测 。
(2)实现方式
在数据库中,悲观锁的实现往往靠数据库提供的锁机制,在对数据记录操作前给记录加排它锁;乐观锁的实现则不会使用数据库的锁机制,一般在表中添加version字段来做类似CAS的自旋操作。
在Java中,synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现;java.util.concurrent.atomic 等原子变量类就是基于CAS机制乐观锁思想的实现。
(3)使用场景
乐观锁适用于多读少写的场景,即线程间的冲突发生较少的时候。若使用synchronized同步锁进行线程阻塞、唤醒切换以及用户态内核态间的切换操作会额外浪费消耗CPU资源; 而CAS机制其实是基于硬件实现的,不需要进入内核与切换线程,操作自旋几率较少,因此可以获得更高的性能。这样可以省去了锁的开销,加大了系统的整个吞吐量。
悲观锁适用于少读多写的场景,即线程间的冲突发生较多的时候。若使用CAS机制的乐观锁实现,这就会导致上层应用会不断的进行重试(比较并交换),这样反倒是降低了系统的性能,所以一般多写的场景下使用悲观锁就比较合适。
2、公平锁与非公平锁
(1)根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁。
公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,有个先来后到的原则,最早请求锁的线程会先获得锁。
非公平锁表示线程获取锁的顺序与线程请求锁的时间早晚无关,先来不一定先获得锁。
(2)实现方式
ReentrantLock 提供了公平锁和非公平锁的实现
非公平锁: ReentrantLock nonfairLock = new ReentrantLock(); ReentrantLock 的无参构造方法是非公平锁,这与有参构造方法传入false的效果一样。
公平锁:ReentrantLock fairLock = new ReentrantLock(true); 有参构造方法传入true。
(3)使用场景
在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。
如果业务中线程处理时间要远长于线程等待,那用非公平锁其实效率并不明显,但是用公平锁会给业务增强很多的可控制性。
3、独占锁与共享锁
(1)根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。
独占锁保证任何时候都只有一个线程能得到锁。
共享锁则可以同时由多个线程持有。
(2)实现方式
ReentrantLock是以独占方式实现的。
ReadWriteLock读写锁,它允许一个资源可以被多个线程同时进行读操作。
(3)使用场景
独占锁适用于多写的场景。因为读操作并不会影响数据的一致性 ,而独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。
共享锁适用于多读的场景。共享锁是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。
4、可重入锁
(1)当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞;但当一个线程再次获取它自己已经获取的锁时,如果不被阻塞,那么这个锁就是可重入锁。
看看下面的例子:
public class ReentrantLockTest { public synchronized void helloA(){ System.out.println("Hello A!"); } public synchronized void helloB(){ System.out.println("Hello B!"); helloA(); } public static void main(String[] args) { ReentrantLockTest test = new ReentrantLockTest(); test.helloB(); } }
执行结果为:
Hello B! Hello A!
上述代码,调用helloB()方法前,线程或先获取内置锁,然后打印输入Hello B!;之后调用helloA()方法,在调用前会去获取内置锁,若内置锁是不可重入的,那么调用线程会一直被阻塞。但结果表明,synchronized内置锁是可重入的。
(2)实现方式
synchronized内置锁是可重入锁。可重入锁的原理是在锁内部维护一个线程标识,用来标志该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为0,说明该锁未被任何线程占用。当一个线程获取了该锁时,计数器值+1,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被挂起;但当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值+1,释放锁把计数器值-1;当计数器值为0时,锁里面的线程标识被置为null,这时被阻塞的其他线程就会被唤醒来竞争该锁。
5、自旋锁
由于 Java 中的线程是与操作系统中的线程一一对应的,所以当一个线程在获取锁(比如独占锁)失败后,会被切换到内核状态而被挂起,当该线程获取到锁时又需要将其切换到内核状态而唤醒该线程。而从用户状态切换到内核状态的开销是比较大的,在一定程度上会影响并发性能。
(1)自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃CPU使用权的情况下,多次尝试获取(默认次数是10 ,可以使用-XX:PreBlockSpinsh参数设置该值),很有可能在后面几次尝试中其他线程己经释放了锁,如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起。由此看来自旋锁是使用CPU时间换取线程阻塞与调度的开销,但是很有可能这些CPU时间白白浪费。
(2)实现方式
CAS机制是自旋锁的主要实现方式。
三、总结
本文主要介绍了Java中基本的八种锁机制,能够为学习并发编程奠定基础,另外这也是大厂面试中经常考察的问题,想进大厂的同学要好好掌握。另外,有一些博客有一些很不错的锁机制的使用案例,可以学习一下,增强对本文的理解:Java多线程——线程八锁案例分析
好了,本期的学习就到这里,不知不觉,又摸了半天鱼。
我是Zhongger,一个在互联网行业摸鱼写代码的打工人,卑微求个【关注】和【在看】,你们的支持是我创作的最大动力,我们下期见~