常见锁策略

一、乐观锁和悲观锁:

(一)乐观锁 和 悲观锁概念

悲观锁:总是假设最坏的情况,认为每次读写数据都会冲突,所以每次在读写数据的时候都会上锁,保证同一时间段只有一个线程在读写数据。

乐观锁:每次读写数据时都认为不会发生冲突,线程不会阻塞,只有进行数据更新时才会检查是否发生冲突,若没有冲突,直接更新,只有冲突了(多个线程都在更新数据)才解决冲突问题。

举栗时间:

同学A 和同学B想请教老师一个问题.

同学A认为 "老师是比较忙的, 我来问问题, 老师不一定有空解答". 因此同学A会先给老师发消息: "老师,你忙嘛? 我下午两点能来找你问个问题嘛?" (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.

同学B认为 "老师是比较闲的, 我来问问题, 老师大概率是有空解答的". 因此同学B直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学B也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.

(二)乐观锁 和 悲观锁的实现

悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.

乐观锁的实现可以引入版本号机制. 借助版本号识别出当前的数据访问是否冲突.

(核心就是,线程能否成功刷新主内存的值。当工作内存的版本号 == 主内存的版本号才能更新,更新成功之后,同步刷新自己的版本号和主内存的版本号,表示此时更新成功。)

1) 线程1 此时准备将其读出( version=1, balance=100 ),线程 2 也读入此信息( version=1, balance=100 )

常见锁策略_第1张图片

2) 线程 1 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 2从其帐户余额中扣除 20 ( 100-20 ); 

常见锁策略_第2张图片

3) 线程 1完成修改工作,连同帐户扣除后余额( balance=50 ),写回到内存中,并将工作内存版本号和主内存版本号加1( version=2 ),; 

常见锁策略_第3张图片

4) 线程 2 完成了操作,试图向主内存中提交数据( balance=80 ),但此时比对版本发现自己工作内存的版本号 != 主内存的版本号。就认为这次操作失败. 

常见锁策略_第4张图片

当线程2的version和主线程的version不相等时,有两种解决办法

1.直接报错,线程2退出,不写回

2.线程2从主内存中读取最新的值50以及版本号2,再次在50的基础上执行-20操作 = 30,然后尝试重新写会主内存。 (CAS策略:不断重试写会,直到成功为止

一般锁的实现都是乐观锁和悲观锁并用的策略。

二、读写锁(适用于线程基本上都在读数据,很少有写数据的情况)

多线程访问数据时,并发读取数据不会有线程安全问题,数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。

将锁分为读锁和写锁

  1. 多个线程并发访问读锁(读数据),则多个线程都能访问到读锁,读锁和读锁是并发的,不互斥。
  2. 两个线程都需要访问写锁(写数据),则这两个线程互斥,只有一个线程能成功的获取到写锁,其他写线程阻塞。
  3. 当一个线程读,另一个线程写(也互斥,只有当写线程结束,读线程才能继续执行。)

        例子:当读者在追连载小说时,只能等作者写完,读者才能阅读

Synchronized不是读写锁,JDK内置了另一个ReentrantReadWriteLock实现读写锁。

三、重量级锁和轻量级锁:

重量级锁: 重量级锁的加锁机制重度依赖操作系统提供的 mutex (互斥锁),线程获取重量级锁失败进入阻塞状态,操作系统进行状态的切换(操作系统频繁地从用户态切换到内核态,或者从内核态切换到用户态),开销非常大。)

轻量级锁:轻量级锁的加锁机制尽可能不使用操作系统的mutex, 而是尽量在用户态执行操作, 实在搞不定了, 再使用 mutex.(很少)进行状态切换,开销较小。

四、自旋锁

while(获取lock == false){
    //不断地循环
}

自旋锁表示当线程获取锁失败后,并不会让出CPU,线程也不阻塞,不会从用户态切换到内核态,而是在CPU上空跑,当锁被释放,此时这个线程就会很快获取到锁。 

理解自旋锁 vs 挂起等待锁

想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~ 挂起等待锁: 陷入沉沦不能自拔.... 过了很久很久之后, 突然女神发来消息, "咱俩要不试试?" (注意, 这个很长的时间间隔里, 女神可能已经换了好几个男票了).

自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能 立刻抓住机会上位.

优点: 自旋锁没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用. 

缺点:如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源

轻量级锁的常用实现就是采用自旋锁:

五、公平锁和非公平锁:

公平锁- 获取锁失败的线程进入阻塞队列,当锁被释放,第一个进入阻塞队列的线程首先获取到锁(等待时间最长的线程获取到锁)。

非公平锁- 获取锁失败的线程进入阻塞队列,当锁被释放,所有在队列中的线程都有机会获取到锁,获取到锁的线程不一定就是等待时间最长的线程。(synchronized就是非公平锁)

六、可重入锁 和不可重入锁

可重入锁:可重入锁的字面意思是“可以重新进入的锁”,获取到对象锁的线程可以再次加锁,这种操作就称为可重入。

Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的。 线程安全问题_explorer363的博客-CSDN博客

不可重入锁: Linux 系统提供的 mutex 是不可重入锁.

一个线程没有释放锁, 然后又尝试再次加锁. 按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第 二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无 法进行解锁操作. 这时候就会死锁

七、CAS 

(一)什么是CAS?

CAS(Compare and swap)比较并交换,是乐观锁的一种实现方法。不会真正阻塞线程,而是不断尝试更新。

CAS的操作流程:

        我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

        1. 比较 A 与 V 是否相等。(比较)

        2. 如果比较相等,将 B 写入 V。(交换)

            若比较不相等,说明此时线程的值A已经过时啦(即主内存中的值已经发生了变化),将当前主内存的最新值V保存到当前的工作内存中,此时无法将B写回主内存,继续循环,直到 A== V,将 B 写入 V.

        3. 返回操作是否成功。

(二)由CAS实现的应用

(1)CAS实现原子类

int i = 0;

i++ ;i--等操作都是非原子性操作,多线程并发会产生线程安全问题。

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的. 典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

常见锁策略_第5张图片

public class AtomicTest {
    static class Counter {
        // 基于整型的原子类
        AtomicInteger count = new AtomicInteger();
        void increase() {
            // i++
            count.incrementAndGet();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count.get());
    }
}

(2)CAS实现自旋锁

自旋锁指的是获取锁失败的线程,不会让出CPU,不会进入阻塞队列,而是在CPU上空转,不断查询当前锁的状态。

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;
   }
}

常见锁策略_第6张图片

(三)由CAS引发的ABA问题

 此时有两个线程t1,t2,同时修改共享变量num,num== A;

正常情况:

只有一个线程会将num值该为正确的值,而另一个线程则无法修改,因为这个线程的值已经过期了(num != A)

1. num == A        

2.  t1 --> num == B  

3.  CAS(V,A,B) 其中 V== B,A==A,B ==Z   V != A,t2在CAS的过程中,主内存的值与工作内存的值不相等,因此,无法修改,需要将最新值读取到工作内存后再次尝试CAS。

特殊情况:

当一个线程连续对num值进行了修改,且最后一次修改将num值又重新修改为了它原来的值,对于另一个线程来说,是可以成功修改num的值的,因为主内存的值 == 工作内存的值

常见锁策略_第7张图片 t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.

解决方案:引入版本号机制 

给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. 

CAS 操作在读取旧值的同时, 也要读取版本号.

真正修改的时候, 如果当前版本号(主内存)和读到的版本号(工作内存)相同, 则修改数据, 并把版本号 + 1.

如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了). 

八、synchronized关键字背后锁的升级流程

基本特点

结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):

        1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.

        2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.

        3. 实现轻量级锁的时候大概率用到的自旋锁策略

        4. 是一种不公平锁

        5. 是一种可重入锁

        6. 不是读写锁

JVM对synchronized锁的升级流程

JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级(自动升级,程序员不用做任何处理)。

   synchronized void increase() {
           val++;
   }

1)无锁:没有任何线程尝试获取锁 (此时没有任何线程调用increase(),因此对象就是无锁状态)

2)  偏向锁:第一个尝试加锁的线程, 优先进入偏向锁状态.

        偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程. 如果后续没有其他线程来竞争该锁, 当这个线程再次进行其他同步操作时(重入或再次执行),就验证下是否为被标记线程,若是直接放行(避免了加锁解锁的开销)

        当有第二个线程也在尝试获取锁后(开始有竞争后),JVM就会取消偏向锁状态,将锁升级为轻量级锁。

3) 轻量级锁: 随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).

         此处的轻量级锁就是通过 CAS 来实现.通过 CAS 检查并更新一块内存 (比如 null => 该线程引用) 如果更新成功, 则认为加锁成功

        如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).

第二个尝试加锁的线程通过自旋的方式来获取轻量级锁,如果还有其他线程在想尝试获取锁,都在自旋等待第二个线程执行结束。

4) 重量级锁 如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁(悲观锁的实现)

竞争非常激烈,多个线程都同时在竞争轻量级锁(一般来说就是当前线程数为CPU核数的一半),or 自旋次数超过默认值以上 , 就会将轻量级锁膨胀为重量级锁。

只要程序中调用了wait()方法,直接会膨胀为重量级锁,无论当前竞争是否激烈

        此处的重量级锁就是指用到内核提供的 mutex .

        执行加锁操作, 先进入内核态. 在内核态判定当前锁是否已经被占用

        如果该锁没有占用, 则加锁成功, 并切换回用户态.

        如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 等待被操作系统唤醒.

经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁.

常见锁策略_第8张图片

 优化操作

1、锁消除

        编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.

有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

         此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加 锁解锁操作是没有必要的, 白白浪费了一些资源开销.因此,JVM会自动进行锁消除。

2、锁粗化

        一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.

        实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁. 但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.

九、java.util.concurrent.lock

Lock接口是标准库的一个接口, 在 JVM 外实现的(基于 Java 实现). 

使用Lock接口需要显示的进行加锁和解锁操作。

lock(): 加锁, 获取锁失败的线程进入阻塞状态,直到其他线程释放锁,再次竞争,如果获取不到锁就死等. 
trylock(超时时间): 加锁, 获取锁失败的线程进入阻塞状态, 等待一定的时间,时间过了若还未获取到锁恢复执行,就放弃加锁,执行其他代码 
unlock(): 解锁

synchronized和lock的区别:

        1.synchronized是Java的关键字,由JVM实现,需要依赖操作系统提供的线程互斥锁(mutex);Lock是标准库的接口,其中一个最常用子类(ReentrantLock,可重入锁),由Java本身实现,不需要依赖操作系统。

        2.synchronized 是隐式的加锁和解锁,而lock需要显示进行加锁和解锁

        3.synchronized 在获取锁失败的线程时,死等;lock可以使用tryLock等待一段时间之后自动放弃加锁,线程恢复执行。

        4.synchronized是非公平锁,RenntrantLock默认是非公平锁,可以在构造方法中传入true开启公平锁。 

 public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

        5.synchronized不支持读写锁,Lock的子类ReentrantReadWriteLock支持读写锁。

一般场景下,使用synchronized足够用了,需要用到超时等待锁,公平锁,读写锁再考虑使用lock接口。

十、死锁

死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线 程被无限期地阻塞,因此程序不可能正常终止。

举个栗子理解死锁

滑稽老哥和女神一起去饺子馆吃饺子. 吃饺子需要酱油和醋.

滑稽老哥抄起了酱油瓶, 女神抄起了醋瓶.

滑稽: 你先把醋瓶给我, 我用完了就把酱油瓶给你.

女神: 你先把酱油瓶给我, 我用完了就把醋瓶给你.

如果这俩人彼此之间互不相让, 就构成了死锁.

酱油和醋相当于是两把锁, 这两个人就是两个线程.

如何避免死锁

死锁产生的四个必要条件:

互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用

不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。

请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。

循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样 就形成了一个等待环路。

当上述四个条件都成立的时候,便形成死锁。

当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。 其中最容易破坏的就是 "循环等待".

你可能感兴趣的:(开发语言,jvm,java)