由于加锁是一个开销较大的行为,为了更好的运用锁,我们对锁进行分类,以便于在不同的情况下使用。
乐观锁与悲观锁
重量级锁与轻量级锁
锁的核心特性“原子性”,主要是依赖于CPU硬件设备提供。首先CPU 提供了 “原子操作指令”。操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁。JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类
公平锁与非公平锁
自旋锁与挂起等待锁
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度。
互斥锁与读写锁
可重入锁与不可重入锁
1. 可重入锁是允许同一个线程多次获取同一把锁。可重入锁也叫做递归锁。
2. 不可重入锁不允许同一个线程多次获取同一把锁,会发生锁竞争,阻塞等待。
注意: Java里只要以Reentrant开头命名的锁都是可重入锁
对synchronized的总结:
在Java中实现加锁操作主要通过synchronized关键字和JUC包下的Lock接口,Lock 是JDK1.5之后引入的线程同步工具 。接下来以synchronized为例,解释以下三种锁优化策略:锁升级、锁消除、锁粗化。
以上讲的偏向锁是程序运行时,jvm做的优化,锁消除则是编译期间,检测当前代码是否需要加锁,如果不加锁但是写了加锁就在编译期间直接消除。
比如我们的StringBuffer类中的关键方法都加了synchronized关键字,当我们在单线程使用时就不需要锁,此时编译器检测到了就会帮我们消除锁。
锁粒度: 指的是我们synchronized修饰的代码块中的代码多少,如果代码越多锁的粒度就越大,代码越少锁的粒度就越小。
我们是不希望频繁的加锁解锁的,会消耗大量资源,所以当我们锁粒度很小时,隔几行代码就有一个锁,此时编译器就会把它优化成一个粒度更大的锁,也就是直接整体加锁,此时就是锁粗化策略。
什么是CAS?
CAS全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功
CAS的伪代码实现:
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
注意: 以上代码只是形象的解释我们CAS的底层工作原理,实际上CAS是CPU上的一条原子指令。
为什么有CAS?
由于CAS只用一条CPU指令,可以实现对变量值的更改,此时我们就可以用它实现原子类,不加锁就能保证线程安全。同样的CAS还能用来实现自旋锁。
Java标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于CAS来实现的。典型的就是 AtomicInteger 类。其中的getAndIncrement 相当于 i++ 操作,这个时候不加锁就能保证原子性实现多线程自增操作。
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
解读: value是需要自增的变量,理解为内存中存储的值。oldValue可以视为寄存器,进入循环判断,寄存器的值是否是等于要自增的变量的值,如果是的,就说明修改不会出现问题,就将寄存器的值加1赋给value,循环结束。如果不等于,就说明此时该变量被别的线程修改了但是还没有记录到寄存器,此时就重新设置oldVlue的值,直到相等时,就说明可以自增了。
简而言之:此处的CAS就是为了确定当前的value是否变过,如果没有改变就可以直接自增,如果改变了就需要先更新再自增。
要想更加理解这个问题可以参考我的另一篇博客线程不安全的几种情况与解决办法,里面有关于原子性以及内存可见性的解说。
由于以上我们可以知道,CAS通过比较内存与寄存器中的值是否相等来判断,这个内存是否改变过。但是会出现一种情况,就是我们的值虽然相等,但是我们并不是没有改变过,而是从a->b->a。
此时就有一定概率出现问题。
举个例子: 假设我们有100块钱,打算取50块钱。如果我们取款机创建两个线程来并发执行扣50的操作。
1)线程1获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50。
2) 线程1 执行扣款成功, 存款被改成 50。 线程2 阻塞等待中。
3) 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败。
但是如果此时有一个线程3向我转账,此时存款从50修改成了100,线程2再执行时发现存款还是100。就会进行-50,此时就会进行两次扣款。
如何解决aba问题?
可以利用CAS实现自旋锁。
自旋锁伪代码:
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
死锁: 当多个线程同时被阻塞,线程中的一个或者多个又或者全部都在等待某个资源被释放,造成线程无限期的阻塞,导致程序不能正常终止就叫做死锁。
由以上死锁的必要条件可以看出,我们解除死锁最容易破坏的条件就是循环等待。
破坏循环等待:
最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号(1, 2, 3…M)。N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁.。这样就可以避免环路等待。
举例:
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
}}}};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
}}}};
t2.start();