灌篮少年 发表于 2024-11-2 20:24:44

Java 多线程(八)—— 锁策略,synchronized 的优化,JVM 与编译器的锁优

前言

本文为 Java 面试小八股,一句话,明白性影象,不能明白就死背吧。
锁策略

悲观锁与乐观锁

悲观锁和乐观锁是锁的特性,并不是特指某个具体的锁。
我们知道在多线程中,锁是会被竞争的,悲观锁就是指锁的竞争程度非常猛烈,很多线程都想用这把锁,为了应对这个场景,我们会额外做一些工作。例如:一把锁,此时有几十个线程都想用,并且同一时刻它们都发出申请锁的请求,这时候锁的竞争程度很高,我们可以采取悲观锁的策略,额外做一些工作。
乐观锁则相反,锁的竞争程度很小,就不需要做额外的工作。例如:这一把锁只有两个线程在竞争,并且这两个线程用锁的概率也不是很高,这时候我们可以采用乐观锁策略。
重量级锁与轻量级锁

重量级锁和轻量级锁是遇到特定的场景而出现的办理方案。
上面我们就提到乐观和悲观的场景,重量级锁适用于悲观的场景,相应的也要付出更高的代价,效率相比轻量级锁要低效。
轻量级锁适用于乐观的场景,要付出的代价也要小很多,效率相比重量级锁要高效。
等待挂起锁与自旋锁

挂起等待锁就是指假如一把锁已经被一个线程占用的时候,发现有其他线程还想竞争这把锁,操纵体系就会让它们阻塞等待,后续唤醒的时候需要由操纵体系的内核来唤醒。
自旋锁就是指假如发现由锁竞争,这时候这些线程不会阻塞等待,而是以忙等的形式进行等待。
看到这里,其实等待挂起锁是适用于悲观的场景下,因为线程竞争猛烈,没须要让它们占着 CPU 资源,直接让它们阻塞,释放出CPU 资源,减少资源的消耗。同时由于唤醒的时候是由操纵体系的内核实现的,所以操纵体系会在内核态和用户态频繁切换,效率也会比较低下。
而自旋锁则适用于乐观的场景,线程以忙等的形式,也就是占用着CPU,但是由于锁竞争不是很猛烈,忙等的线程很快就可以获取到锁,所以没须要阻塞等待,因为操纵体系的内核唤醒线程的效率要低效一些,所以自旋锁的效率会比等待挂起锁的效率要高。
互斥锁与读写锁

互斥锁就是加锁之后,拥有这把锁的线程才能进行操纵,其他线程必须等待拿到锁之后才能进行自己的操纵,这就是互斥。
读写锁分为读锁、写锁,因为我们知道线程安全题目是因为写操纵而引起的,但是读操纵是不会发生线程安全题目的,而读写锁就是针对读操纵和写操纵进行加锁,读锁和读锁是不会互斥的,写锁与读锁是会互斥的,写锁与写锁是会互斥的(这里可以参考MySQL的幻读、脏读、不可重复读了)
公平锁与非公平锁

公平锁是指锁的分配是按线程的等待时长来分配的,举个例子:假设一把锁已经被一个线程占用,此时有三个线程都想竞争这把锁,那这时候我们会使用额外的数据结构来生存这些线程并且记录每个线程的等待时长,等到锁被释放的时候,操纵体系会优先把这把锁分配给等待时长最长的线程,这也避免了线程饥饿。
非公平锁就是随机分配,不按 “先来先得” 的规矩
可重入锁与不可重入锁

可重入锁就是当一个线程拥有这把锁的时候,可以进行重复的加锁。
不可重入锁则相反,纵然你这个线程拥有了这把锁,但是还是不能对其进行重复加锁。
synchronized 的优化

根据上面的锁策略,我们来总结一下 synchronized 的特性:
   synchronized 具有自顺应性
synchronzied 开始时是采取乐观锁策略,假如锁的冲突频繁,则转换为悲观锁
开始时是轻量级锁,假如锁冲突频繁,则转换为重量级锁
synchronized 实现轻量级锁的时候采用自旋锁策略
synchronized 是不公平锁,可重入锁,互斥锁,不是读写锁
锁升级

JVM 会将 synchronized 的锁分为四个状态:无锁、偏向锁、自旋锁、重量级锁。
https://i-blog.csdnimg.cn/direct/92d5ff7669d84ad0b0020c9ebcc2d0c7.png
当还没进入synchronized 的时候,处于无锁状态,一旦进入 synchronized 代码块就会变成偏向锁,偏向锁并非真正加锁,而是通过标志的方式,以此来区分是否真正加锁了。偏向锁本质上相当于 “耽误加锁”,能不加锁就不加锁,避免了不须要的加锁开销,这也是一种懒汉模式的体现。
一旦产生锁竞争,偏向锁就会升级为自旋锁,也就是轻量级锁,假如竞争非常猛烈,进一步升级为重量级锁。
   synchronized 只能进行锁升级,但是不能进行锁降级!!!
JVM 与编译器的锁优化

锁消除

JVM 会自动检测出一些没有须要加锁的操纵,避免这些无意义的加锁操纵带来的不须要的开销,JVM 会把这些锁给消除,也就是说你代码加锁了,但是 JVM 给删除了。
大家不消担心这个优化会产生线程安全题目,因为 JVM 的锁消除是在100% 确定这个锁就是一个没须要加的锁,JVM 才会进行锁消除。
锁粗化

起首先容一个概念,锁的粒度:加锁与解锁之间包罗的代码指令越多,锁就越粗;相反,加锁与解锁之间包罗的代码指令越少,锁就越细。
public class Test {
    public static int sum = 0;
    public static int count = 10000;
    public static int total = 1000;
    public static void main(String[] args) {
      Object locker = new Object();
      Thread t = new Thread(() -> {
            for(int i = 0; i < 5000; i++) {
                synchronized (locker) {
                  sum++;
                }
                synchronized (locker) {
                  count--;
                }
                synchronized (locker) {
                  total--;
                }
            }
      });
    }
}
上面的代码就属于锁的粒度太细了,频繁加锁解锁。
      Thread t2 = new Thread(() -> {
            for(int i = 0; i < 5000; i++) {
                synchronized (locker) {
                  sum++;
                  count--;
                  total--;
                }
            }
      });
这个代码就是锁的粒度粗,加锁和解锁的次数比较少。
⼀段逻辑中假如出现多次加锁解锁,编译器 和 JVM会自动进行锁的粗化。
ReentrantLock

ReentrantLock 和 synchronized 是并列关系,都是用来加锁的,并且都是可重入锁。
简单使用先容,ReentrantLock 使用 lock() 加锁,unlock() 来解锁,为了避免我们因为加锁和解锁之间有return 或者 抛出异常等等情形没能进入解锁操纵,所以这里使用 finally 来包罗 unlock() 代码行,避免忘记解锁。
      ReentrantLock locker2 = new ReentrantLock();
      Thread t3 = new Thread(() -> {
            try {
                locker2.lock();
                count++;
            } finally {
                locker2.unlock();
            }   
      });
   synchroinzed 和 ReentrantLock 的区别:
synchronized 是 Java提供的关键字,是 JVM 内部通过 C++ 实现的,ReentrantLock 是Java标准库提供的类,由Java代码实现
synchronized 是 通过代码块来实现加锁和解锁的,ReentrantLock 通过 lock() 加锁,unlock() 解锁,一定要留意 unlock() 可能存在未被调用的情况。
ReentrantLock 另有一个 tryLock() 这个方法的调用不会线程产生阻塞,假如加锁乐成则返回 true,加锁失败则返回 false,接下来由调用者来根据返回值决定接下来怎么做。可以设置超时时间,当等待时间达到超时时间的时候再返回true / false
ReentrantLock 提供了公平锁的实现,ReentrantLock locker = new ReentrantLock(true);,默认情况下好坏公平锁。
https://i-blog.csdnimg.cn/direct/4b1d07e66e2c4ced95d14ffcc5ac8a6d.png
ReentrantLock 搭配的通知等待机制是由Condition 类实现的,相比于 synchronized 的 wait / notify 的功能更强大一些。
    synchronized 和 ReentrantLock 都是可重入的互斥锁。
CAS

   CAS 全称是 Compare and swap,比较并互换
    CAS 在 CPU 里是一条指令,具有原子性。
因此 CAS 操纵是线程安全的
举个例子:假设内存原始数据为 V,把这个数据放入寄存器 1 和 寄存器 2 中,数据的加减等操纵的效果由寄存器 2 生存。CAS 会先检测原始数据 V 和寄存器 1 的数值是否同等,假如同等的话,可以实行修改也就是把寄存器 2 的效果放入内存中。
下面给出 CAS 的伪代码进行进一步的明白:
boolean CAS(address, expectValue, swapValue) {
        if (&address == expectedValue) {
                &address = swapValue;
                        return true;
        }
        return false;
}
第一个参数是内存的数值,第二个参数是寄存器 1 的数值,第三个参数是寄存器 2 的数值。
起首判断内存的数值是否和寄存器的数值同等,假如同等则进行寄存器 2 和内存数值的互换操纵,留意 这本质上在 CPU 里是一条指令,具有原子性。
   明白的指明:if-else 和 三目运算符在 CPU 里不是一条指令,和 CAS 还是由区别的。
原子类

CPU 有 CAS 指令,并且给操纵体系提供了 CAS 的使用接口,操纵体系对 CAS 进一步封装,给用户提供相应的接口,C++ 可以直接进行调用,而JVM 是由 C++ 实现的再次对 CAS 进行封装,给Java 步伐员提供了 原子类。在这个 java.util.concurrent.atomic包下就是我们的原子类了。
https://i-blog.csdnimg.cn/direct/4a82523b9f88452483fdb597ecd4895b.png
下面是原子类的伪代码:
class AtomicInteger {
        private int value;
        public int getAndIncrement() {
                int oldValue = value;
                while ( CAS(value, oldValue, oldValue+1) != true) {
                        oldValue = value;
                }
                return oldValue;
    }
}
oldValue 是寄存器,由于Java没有寄存器的使用,所以这里用 int 类型取代。
getAndIncrement() 其实就是 ++ 自增的操纵,起首先把内存的数值(value)读到寄存器 1 中,CAS 指令 起首判断 value 是否和寄存器 1 中的数值 oldValue 相称,假如相称就把寄存器 2 的 oldValue + 1 的效果放到内存中,返回 true,否则返回 false 并且进入循环体再次读取内存的数值放入寄存器 1 中。
面对多线程下同时修改一个变量的时候,原子类是最佳的选择。
import java.util.concurrent.atomic.AtomicInteger;

public class Demo1 {
    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {

      Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
      });

      Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
      });

      t1.start();
      t2.start();
      t1.join();
      t2.join();

      System.out.println("count = " + count.get());
    }
}
https://i-blog.csdnimg.cn/direct/1b6f5594be1442a6bba6490934933d1d.png
使用 CAS 实现自旋锁

下面是 使用 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;
        }
}
   焦点代码:while(!CAS(this.owner, null, Thread.currentThread())){} 当 这个锁的拥有者 为 null 的时候,才能由线程Thread.currentThread() 获取这把锁的操纵并返回true,否则该线程以忙等的形式等待这把锁。
ABA 题目

ABA 题目是什么?
我们知道 CAS 开始之前会先把内存的数值读到寄存器里,在进行 CAS 的操纵之前,可能调理过来别的线程,这个线程对这个内存的数值进行了修改操纵,然后又改返来了,看上去这个数值没有任何变化,实际上这个数据已经被动过了,接着把 CAS 调理过来实行,CAS 起首判断内存的数值是否和寄存器的数值同等,假如同等,进行互换操纵,这时候数值肯定是同等的,所以互换操纵正常被实行了。在进行内存数值和寄存器数值判断是否相称之前内存数值是否被改了又改过,这就是 ABA 题目。
ABA 题目会带来什么BUG?
假设一个人叫做白糖过来取500块钱,假设余额有 4k,这时候 ATM 机有点卡顿,这时候白糖进行了多次按下取款的操纵,恰好这时候白糖的好朋友天王星发个信息说之前欠你的500块现在转账还你。
由于多次按下取款操纵,就会产生多个取款的线程来实行取款操纵,此时中心夹了一个还款操纵的线程,大家来看一下下面的流程图:
https://i-blog.csdnimg.cn/direct/5e14157aba8e47e1b8f08650dbfd6e8d.png
取款线程 t1 把 account 修改为 3500, 还款线程将 account 修改为 4000, 接着又来了 取款线程 t3 由于内存4000 和寄存器的数值保存的 4000 是同等,所以又将余额修改为了 3500,你会发现白糖小伙就拿出了 500 块,但是余额却多扣了 500, 完了血亏 500,可怜的白糖又要辛劳打工了。
这种变乱虽然发生概率极小,但是在庞大的请求数量面前还是不能忽视这个 bug 的。
   如何办理 ABA 题目???
因为余额是可以加又可以减的变量,所以会出现上述极度的BUG,但是假如我们换一个指标来作为判断标准的话就可以避免上述的BUG,这里我们可以使用版本号来作为判断的指标,每次修改之后版本号就 + 1,每次进行修改操纵的时候判断内存的版本号和寄存器的版本号是否雷同
下面给一个伪代码:
      int oldVersion = version;

      if(CAS(version, oldVersion, oldVersion + 1)) {
            account += 500;
      }

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: Java 多线程(八)—— 锁策略,synchronized 的优化,JVM 与编译器的锁优