多线程-进阶(1)

目的:
了解熟悉常⻅的锁策略.
接下来讲解的锁策略不仅仅是局限于 Java . 任何和 "锁" 相关的话题, 都可能会涉及到以下内容.
这些特性主要是给锁的实现者来参考的.
普通的程序猿也需要了解⼀些, 对于合理的使⽤锁也是有很⼤帮助的.

1.乐观锁 vs 悲观锁  (站在锁预估冲突角度)

悲观锁:

总是假设最坏的情况,每次去拿数据的时候都认为别⼈会修改,所以每次在拿数据的时候都会上锁,这样别⼈想拿这个数据就会阻塞直到它拿到锁。

乐观锁:

假设数据⼀般情况下不会产⽣并发冲突,所以在数据进⾏提交更新的时候,才会正式对数据是否产⽣并发冲突进⾏检测,如果发现并发冲突了,则让返回⽤⼾错误的信息,让⽤⼾决定如何去做。

举个栗⼦: 同学 A 和 同学 B 想请教⽼师⼀个问题.
同学 A 认为 "⽼师是⽐较忙的, 我来问问题, ⽼师不⼀定有空解答". 因此同学 A 会先给⽼师发消息: "⽼师 你忙嘛? 我下午两点能来找你问个问题嘛?" (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等⼀段时间, 下次再来和⽼师确定时间. 这个是悲观锁.
同学 B 认为 "⽼师是⽐较闲的, 我来问问题, ⽼师⼤概率是有空解答的". 因此同学 B 直接就来找⽼师.(没加锁, 直接访问资源) 如果⽼师确实⽐较闲, 那么直接问题就解决了. 如果⽼师这会确实很忙, 那么同学 B 也不会打扰⽼师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.

 

乐观锁一般情况下都不会出现冲突,如果出现锁竞争较多的情况还是使用悲观锁。

Synchronized 初始使⽤乐观锁策略. 当发现锁竞争⽐较频繁的时候, 就会⾃动切换成悲观锁策略。

 

 2.重量级锁 vs 轻量级锁(站在开锁开销角度)

重量级锁: 加锁机制重度依赖了 OS 提供了 mutex
⼤量的内核态⽤⼾态切换
很容易引发线程的调度

轻量级锁: 加锁机制尽可能不使⽤ mutex, ⽽是尽量在⽤⼾态代码完成. 实在搞不定了, 再使⽤ mutex.  

少量的内核态⽤⼾态切换.
不太容易引发线程调度.

理解⽤⼾态 vs 内核态  

在窗⼝外, ⾃⼰做, 这是⽤⼾态. ⽤⼾态的时间成本是⽐较可控的.
在窗⼝内, ⼯作⼈员做, 这是内核态. 内核态的时间成本是不太可控的.
如果办业务的时候反复和⼯作⼈员沟通, 还需要重新排队, 这时效率是很低的.
synchronized 开始是⼀个轻量级锁. 如果锁冲突⽐较严重, 就会变成重量级锁.

3.⾃旋锁(Spin Lock)VS挂起等待锁

 

想象⼀下, 去追求⼀个⼥神. 当男⽣向⼥神表⽩后, ⼥神说: 你是个好⼈, 但是我有男朋友了~~
挂起等待锁: 陷⼊沉沦不能⾃拔.... 过了很久很久之后, 突然⼥神发来消息, "咱俩要不试试?" (注意, 这个很⻓的时间间隔⾥, ⼥神可能已经换了好⼏个男票了).
⾃旋锁: 死⽪赖脸坚韧不拔. 仍然每天持续的和⼥神说早安晚安. ⼀旦⼥神和上⼀任分⼿, 那么就能⽴刻 抓住机会上位.
也就是说:
⾃旋锁获取不到锁就会一直等待索德释放而得到锁。
挂起等待锁获取不到锁,就会等待系统释放锁,等待系统安排锁给他
⾃旋锁是⼀种典型的 轻量级锁 的实现⽅式.
挂起等待锁是一种典型 重量级锁 的实现方法。
优点: 没有放弃 CPU, 不涉及线程阻塞和调度, ⼀旦锁被释放, 就能第⼀时间获取到锁.
缺点: 如果锁被其他线程持有的时间⽐较久, 那么就会持续的消耗 CPU 资源. (⽽挂起等待的时候是不消耗 CPU 的).
synchronized 中的轻量级锁策略⼤概率就是通过⾃旋锁的⽅式实现的.

 

4. 公平锁 vs ⾮公平锁

公平锁: 遵守 "先来后到". B ⽐ C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.

 ⾮公平锁: 不遵守 "先来后到".A释放锁之后, B 和 C 都有可能获取到锁.

 注意:

操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是⾮公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
公平锁和⾮公平锁没有好坏之分, 关键还是看适⽤场景.

synchronized 是⾮公平锁.  

5. 可重⼊锁 vs 不可重⼊锁

可重⼊锁的字⾯意思是“可以重新进⼊的锁”,即允许同⼀个线程多次获取同⼀把锁。

理解 "把⾃⼰锁死"
⼀个线程没有释放锁, 然后⼜尝试再次加锁.

⽽ Linux 系统提供的 mutex 是不可重⼊锁。

synchronized 是可重⼊锁。

6.读写锁

多线程之间,数据的读取⽅之间不会产⽣线程安全问题,但数据的写⼊⽅互相之间以及和读者之间都需要进⾏互斥。如果两种场景下都⽤同⼀个锁,就会产⽣极⼤的性能损耗。所以读写锁因此⽽产⽣。
读写锁(readers-writer lock),看英⽂可以顾名思义,在执⾏加锁操作时需要额外表明读写意图,复数读者之间并不互斥,⽽写者则要求与任何⼈互斥。

⼀个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据. 

读写锁的应用场景:

  • 两个线程都只是读⼀个数据, 此时并没有线程安全问题. 直接并发的读取即可.
  • 两个线程都要写⼀个数据, 有线程安全问题.
  • ⼀个线程读另外⼀个线程写, 也有线程安全问题.

 

读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现
了读写锁.
ReentrantReadWriteLock.ReadLock 类表⽰⼀个读锁. 这个对象提供了 lock / unlock ⽅法
进⾏加锁解锁.
ReentrantReadWriteLock.WriteLock 类表⽰⼀个写锁. 这个对象也提供了 lock / unlock
⽅法进⾏加锁解锁.

 

读加锁和读加锁之间, 不互斥.
写加锁和写加锁之间, 互斥.
读加锁和写加锁之间, 互斥.
注意, 只要是涉及到 "互斥", 就会产⽣线程的挂起等待. ⼀旦线程挂起, 再次被唤醒就不知道隔了多久了.
因此尽可能减少 "互斥" 的机会, 就是提⾼效率的重要途径。

 Synchronized 不是读写锁.

7. 前面相关⾯试题

1. 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?

悲观锁认为多个线程访问同⼀个共享变量冲突的概率较⼤, 会在每次访问共享变量之前都去真正加锁.乐观锁认为多个线程访问同⼀个共享变量冲突的概率不⼤. 并不会真的加锁, ⽽是直接尝试访问数据.在访问的同时识别当前的数据是否出现访问冲突.
悲观锁的实现就是先加锁(⽐如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待. 乐观锁的实现可以引⼊⼀个版本号. 借助版本号识别出当前的数据访问是否冲突. (实现细节参考上⾯的图).
1. 介绍下读写锁?
读写锁就是把读操作和写操作分别进⾏加锁.
读锁和读锁之间不互斥.
写锁和写锁之间互斥.
写锁和读锁之间互斥.
读写锁最主要⽤在 "频繁读, 不频繁写" 的场景中.
1.什么是⾃旋锁,为什么要使⽤⾃旋锁策略呢,缺点是什么?
如果获取锁失败, ⽴即再尝试获取锁, ⽆限循环, 直到获取到锁为⽌. 第⼀次获取锁失败, 第⼆次的尝试 会在极短的时间内到来. ⼀旦锁被其他线程释放, 就能第⼀时间获取到锁.
相⽐于挂起等待锁,
优点: 没有放弃 CPU 资源, ⼀旦锁被释放就能第⼀时间获取到锁, 更⾼效. 在锁持有时间⽐较短的场景下⾮常有⽤.
缺点: 如果锁的持有时间较⻓, 就会浪费 CPU 资源.
1. synchronized 是可重⼊锁么?

 

是可重⼊锁.
可重⼊锁指的就是连续两次加锁不会导致死锁.
实现的⽅式是在锁中记录该锁持有的线程⾝份, 以及⼀个计数器(记录加锁次数). 如果发现当前加锁的 线程就是持有锁的线程, 则直接计数⾃增。

8 .CAS

CAS: 全称Compare and swap,字⾯意思:”⽐较并交换“,⼀个 CAS 涉及到以下操作:

1. ⽐较 A 与 V 是否相等。(⽐较)
2. 如果⽐较相等,将 B 写⼊ V。(交换)
3. 返回操作是否成功。

CAS 伪代码 

boolean CAS(address, expectValue, swapValue) {
     if (&address == expectedValue) {
         &address = swapValue;
         return true;
     }
     return false;
}
CAS 是怎么实现的(了解)
针对不同的操作系统,JVM ⽤到了不同的 CAS 实现原理,简单来讲:
java 的 CAS 利⽤的的是 unsafe 这个类提供的 CAS 操作;
unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
Atomic::cmpxchg 的实现使⽤了汇编的 CAS 操作,并使⽤ cpu 硬件提供的 lock 机制保证其原⼦
性。
简⽽⾔之,是因为硬件予以了⽀持,软件层⾯才能做到

 

8.1CAS 应⽤

常见的原子类:

以AtomicInteger为例

addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;

1) 实现原⼦类

标准库中提供了 java.util.concurrent.atomic 包, ⾥⾯的类都是基于这种⽅式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
来个例子:
假设两个线程同时调⽤ getAndIncrement
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger atomicInteger = new AtomicInteger();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 10000; i++) {
                atomicInteger.getAndIncrement();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 10000; i++) {
                atomicInteger.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(atomicInteger.get());
    }

输出结果:

 典型的就是 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;
        }
    }
工作原理:
1. 两个线程都读取 value 的值到 oldValue 中. (oldValue 是⼀个局部变量, 在栈上. 每个线程有⾃⼰的栈)

2. 线程1 先执⾏ CAS 操作. 由于 oldValue 和 value 的值相同, 直接进⾏对 value 赋值.  

CAS 是直接读写内存的, ⽽不是操作寄存器.
CAS 的读内存, ⽐较, 写内存操作是⼀条硬件指令, 是原⼦的.

 

3. 线程2 再执⾏ CAS 操作, 第⼀次 CAS 的时候发现 oldValue 和 value 不相等, 不能进⾏赋值. 因此需要进⼊循环. 在循环⾥重新读取 value 的值赋给 oldValue。

4. 线程2 接下来第⼆次执⾏ CAS, 此时 oldValue 和 value 相同, 于是直接执⾏赋值操作。 

5. 线程1 和 线程2 返回各⾃的 oldValue 的值即可 

通过形如上述代码就可以实现⼀个原⼦类. 不需要使⽤重量级锁, 就可以⾼效的完成多线程的⾃增操作。

8.2CAS 的 ABA 问题

什么是 ABA 问题

正常的过程

1. 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
2. 线程1 执⾏扣款成功, 存款被改成 50. 线程2 阻塞等待中.
3. 轮到线程2 执⾏了, 发现当前存款为 50, 和之前读到的 100 不相同, 执⾏失败.
异常的过程
1. 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
2. 线程1 执⾏扣款成功, 存款被改成 50. 线程2 阻塞等待中.
3. 在线程2 执⾏之前, 滑稽的朋友正好给滑稽转账 50, 账⼾余额变成 100 !!
4. 轮到线程2 执⾏了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执⾏扣款操作
异常过程扎实:
解决⽅案
给要修改的值, 引⼊版本号. 在 CAS ⽐较数据当前值和旧值的同时, 也要⽐较版本号是否符合预期,
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
如果当前版本号⾼于读到的版本号. 就操作失败(认为数据已经被修改过了).

对⽐理解上⾯的转账例⼦ 

相关⾯试题 

1. 讲解下你⾃⼰理解的 CAS 机制
全称 Compare and swap, 即 "⽐较并交换". 相当于通过⼀个原⼦的操作, 同时完成 "读取内存, ⽐较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的⽀撑

1. ABA问题怎么解决?  

给要修改的数据引⼊版本号. 在 CAS ⽐较数据当前值和旧值的同时, 也要⽐较版本号是否符合预期. 如果发现当前版本号和之前读到的版本号⼀致, 就真正执⾏修改操作, 并让版本号⾃增; 如果发现当前版本号⽐之前读到的版本号⼤, 就认为操作失败。

9.synchronized 原理的总结:

基本特点
结合上⾯的锁策略, 我们就可以总结出, synchronized 具有以下特性(只考虑 JDK 1.8):
1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
2. 开始是轻量级锁实现, 如果锁被持有的时间较⻓, 就转换成重量级锁.
3. 实现轻量级锁的时候⼤概率⽤到的⾃旋锁策略
4. 是⼀种不公平锁
5. 是⼀种可重⼊锁
6. 不是读写锁

加锁⼯作过程 

JVM 将 synchronized 锁分为 ⽆锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进⾏依次升级。

1) 偏向锁

第⼀个尝试加锁的线程, 优先进⼊偏向锁状态.
举个栗⼦理解偏向锁
假设男主是⼀个锁, ⼥主是⼀个线程. 如果只有这⼀个线程来使⽤这个锁, 那么男主⼥主即使不领证结 婚(避免了⾼成本操作), 也可以⼀直幸福的⽣活下去.
但是⼥配出现了, 也尝试竞争男主, 此时不管领证结婚这个操作成本多⾼, ⼥主也势必要把这个动作完成了, 让⼥配死⼼.

 2) 轻量级锁

随着其他线程进⼊竞争, 偏向锁状态被消除, 进⼊轻量级锁状态(⾃适应的⾃旋锁).
此处的轻量级锁就是通过 CAS 来实现。
通过 CAS 检查并更新⼀块内存 (⽐如 null => 该线程引⽤)
如果更新成功, 则认为加锁成功
如果更新失败, 则认为锁被占⽤, 继续⾃旋式的等待(并不放弃 CPU).
⾃旋操作是⼀直让 CPU 空转, ⽐较浪费 CPU 资源.
因此此处的⾃旋不会⼀直持续进⾏, ⽽是达到⼀定的时间/重试次数, 就不再⾃旋了.

3) 重量级锁

 

此处的重量级锁就是指⽤到内核提供的 mutex .
执⾏加锁操作, 先进⼊内核态.
在内核态判定当前锁是否已经被占⽤
如果该锁没有占⽤, 则加锁成功, 并切换回⽤⼾态.
如果该锁被占⽤, 则加锁失败. 此时线程进⼊锁的等待队列, 挂起. 等待被操作系统唤醒.
经历了⼀系列的沧海桑⽥, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁.

 其他的优化操作

锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.
什么是 "锁消除"
有些应⽤程序的代码中, ⽤到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
此时每个 append 的调⽤都会涉及加锁和解锁. 但如果只是在单线程中执⾏这个代码, 那么这些加锁解锁操作是没有必要的, ⽩⽩浪费了⼀些资源开销.
锁粗化
⼀段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会⾃动进⾏锁的粗化.
锁的粒度: 粗和细
实际开发过程中, 使⽤细粒度锁, 是期望释放锁的时候其他线程能使⽤锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会⾃动把锁粗化, 避免频繁申请释放锁.

10.  JUC(java.util.concurrent) 的常⻅类

Callable 接⼝

Callable 是⼀个 interface . 相当于把线程封装了⼀个 "返回值". ⽅便程序猿借助多线程的⽅式计算结果.
和Runable的rnu方法相识,但是Callable的call方法有返回值

 

代码⽰例: 创建线程计算 1 + 2 + 3 + ... + 1000, 使⽤ Callable 版本 

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();
        int result = futureTask.get();
        System.out.println(result);
    }

 解释:

输出结果:

11. ReentrantLock (相识于Thread)

可重⼊互斥锁. 和 synchronized 定位类似, 都是⽤来实现互斥效果, 保证线程安全.

 

ReentrantLock 的⽤法:
lock(): 加锁, 如果获取不到锁就死等.
trylock(超时时间): 加锁, 如果获取不到锁, 等待⼀定的时间之后就放弃加锁.
unlock(): 解锁

11.1ReentrantLock 和 synchronized 的区别:

synchronized 是⼀个关键字, 是 JVM 内部实现的(⼤概率是基于 C++ 实现). ReentrantLock 是标准 库的⼀个类, 在 JVM 外实现的(基于 Java 实现).
synchronized 使⽤时不需要⼿动释放锁. ReentrantLock 使⽤时需要⼿动释放. 使⽤起来更灵活, 但 是也容易遗漏 unlock.
synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的⽅式等待⼀段时间就放 弃.
synchronized 是⾮公平锁, ReentrantLock 默认是⾮公平锁. 可以通过构造⽅法传⼊⼀个 true 开启 公平锁模式.
// ReentrantLock 的构造⽅法
public ReentrantLock(boolean fair) {
   sync = fair ? new FairSync() : new NonfairSync();
}
更强⼤的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是⼀个
随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定
的线程.(了解)

如何选择使⽤哪个锁?

锁竞争不激烈的时候, 使⽤ synchronized, 效率更⾼, ⾃动释放更⽅便.
锁竞争激烈的时候, 使⽤ ReentrantLock, 搭配 trylock 更灵活控制加锁的⾏为, ⽽不是死等.
如果需要使⽤公平锁, 使⽤ ReentrantLock。

 

评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

再无B~U~G

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值