【多线程】锁策略

目录

1.乐观锁 悲观锁

2.重量级锁 轻量级锁

3.自旋锁 挂起等待锁

4.读写锁

5.可重入锁  不可重入锁

6.公平锁 非公平锁

7.CAS(compare and swap)

8.基于CAS实现线程安全的方式

9.CAS应用场景

9.1 基于CAS实现原子类

9.2 基于CAS实现自旋锁 

 10.ABA问题及解决方案

11.synchronized几个重要的机制

(1)锁升级

(2)锁消除

(3)锁粗化


1.乐观锁 悲观锁

是一类锁的一种特性,不是具体的一把锁。悲观还是乐观,是对后续锁冲突是否激烈(频繁)给出的预测

如果预测接下来锁冲突的概率不大,就可以少做一些工作。称为乐观锁。

如果预测接下来锁冲突的概率很大,就应该多做一些工作,称为悲观锁。

2.重量级锁 轻量级锁

重量级锁,即锁的开销大,轻量级锁,即锁的开销小。和悲观乐观锁是有关联的。乐观锁通常也是轻量级的锁,悲观锁通常也是重量级的锁。

区别:乐观悲观锁是预测锁冲突的概率,而重量级轻量级锁则是实际消耗的开销

3.自旋锁 挂起等待锁

自旋锁就属于是轻量级锁的一种典型实现。往往是在纯用户态实现。比如使用一个while循环,不停的检查当前锁是否被释放,如果没释放,就继续循环,释放了就获取到锁并结束循环。这就相当于一种忙等,消耗CPU但是换来更快的响应速度。

挂起等待锁就属于是重量级锁的一种典型实现。需要借助系统API来实现,一旦出现所竞争了,就会在内核中触发一系列的操作。(比如让这个线程进入阻塞的状态,暂时不参与CPU调度)。

总结:

  • 上述锁策略中,乐观锁,轻量级锁,自旋锁是一组锁,可以说存在一定联系。
  • 悲观锁,重量级锁,挂起等待是一组锁,存在一定联系。

        

4.读写锁

  • 读加锁:读的时候,能读,不能加锁。
  • 写加锁:写的时候,不能读,也不能加锁。
  1. 读锁是共享锁,写锁是排它锁,读锁和写锁不能同时存在。读写锁,是把加锁操作,分成读锁和写锁。两个线程加锁过程中,读锁和读锁之间不会产生竞争(多线程读取同一个数据,没有线程安全问题),而读锁和写锁,写锁和写锁之间是有竞争的。
  2. 读锁不能升级为写锁。
  3. 写锁可以降级为读锁。

注意与数据库读写加锁的区别:

  • 数据库读加锁:  读的时候不能写。
  • 数据库写加锁:写的时候不能读。

5.可重入锁  不可重入锁

一个线程针对同一把锁,连续加锁两次,若不会死锁,就是可重入锁。若死锁,就是不可重入锁。

synchronized就是可重入锁。

6.公平锁 非公平锁

当有很多线程尝试去获取加同一把锁的时候,只有一个线程能够拿到锁,其他线程阻塞等待,这是锁的特性。那么当这个线程释放锁之后,接下来哪个线程能拿到锁?

公平锁:按照先来后到顺序获取加锁。

非公平锁:在剩下的线程当中已均等的概率来重新竞争锁。

上述这些锁策略都是描述了一把锁的基本特点的。synchronized这把锁,属于哪种锁策略呢?

  • 对于"悲观乐观",是自适应的。
  • 对于"重量轻量",是自适应的。
  • 对于"自旋挂起",是自适应的。
  • 不是读写锁。
  • 是可重入锁。
  • 是非公平锁。

初始情况下,synchronized会预测当前的锁冲突的概率,若概率不大,会以乐观锁的模式运行(也就是轻量级锁,基于自旋锁的方式实现)。若概率大,锁冲突情况多,synchronized就会升级成悲观锁,(也就是重量级锁,基于挂起等待的方式实现)。

7.CAS(compare and swap)

比如有一个内存M,还有两个寄存器A,B,CAS(M,A,B)

如果M和A的值相同的话,就把M和B里的值进行交换,返回true。如果值不相同,就什么都不做,返回false。

【多线程】锁策略_第1张图片

CAS其实是一个cpu指令,比较较换的是内存和寄存器。一个cpu指令,就能完成上述比较交换的逻辑。单个的cpu指令是原子的!!就可以使用CAS完成一些操作,进一步代替"加锁"。这给编写线程安全的代码,进入了新的思路。

8.基于CAS实现线程安全的方式

也称为"无锁编程"。

  • 优点:保证线程安全,同时避免阻塞,提高了效率。
  • 缺点:代码更加复杂,难以理解。只能够适合一些特定场景,不如加锁方式更普适。

CAS本质上是cpu提供的指令,又进一步被操作系统封装,提供成API,又因为操作系统多样,其提供的API也是不同,于是JVM对这些API又进行了统一封装,使得使用者不论在哪个操作系统上使用,只需要掌握一套API即可。下面举出几种应用场景。

9.CAS应用场景

9.1 基于CAS实现原子类

比如count++,就不是原子的,因为一个count++过程对应着cpu上三个指令(load,add,save),但是使用CAS操作会让这个过程成为原子的。

如下代码中多个线程对同一个变量进行修改就会发生线程安全问题,结果与预期不符。

public class test {

    public static  int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for(int i=0;i<5000;i++) {
                count++;
            }
        });

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

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

编写代码中,我们不能直接使用CAS操作,针对这些,Java提供了API,AtomicInteger类 。atomic翻译为原子。

接下来用Java提供的 AtomicInteger类 实现。注意代码中AtomicInteger的用法。

public class Demo14 {

    public static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for(int i=0;i<5000;i++) {
                // count++
                count.getAndIncrement();
                // ++count
                //count.incrementAndGet();
                // count--
                //count.getAndDecrement();
                // --count
                //count.decrementAndGet();
            }
        });

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

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

通过进一步查看 getAndIncrement 方法的源码:

【多线程】锁策略_第2张图片 我们会发现其底层使用了CAS操作的。所谓线程不完全,实质上是进行自增的过程中,线程穿插执行了。CAS是让这里的自增不能穿插执行,核心思路是和加锁类似的。但并没有真正加锁。加锁方式是通过阻塞的方式,避免穿插。CAS则是通过重试的方式,避免穿插。

9.2 基于CAS实现自旋锁 

伪代码:

【多线程】锁策略_第3张图片

 10.ABA问题及解决方案

CAS进行操作的关键,是通过值"没有发生变化"来作为"没有其他线程穿插执行"的判断依据。

但是有一种极端的情况,可能有一个线程穿插进来,把值从 A -> B -> A了。看起来这个值是没变,但是实际上已经被穿插执行了。

举个常见的例子:去银行取钱,假设卡里有1000,我准备取走500,当我刚好取钱成功的时候,朋友给我又转了500,这样,原本我取完钱朋友给我又转了500,卡里应该有1000,但因为ABA问题可能会导致500被连续扣了两次,导致卡里只剩500。

解决办法:

  • 设置一个向一个方向增长的变量记录(不要反复横跳),即可以进入版本号,约定版本号只能增加不能减少。这样在使用CAS判断的时候,就直接判断版本号是否变化。

11.synchronized几个重要的机制

(1)锁升级

锁升级的过程是单向的,不能降级。

偏向锁其实就是没有锁竞争的时候,就吊着不加锁,这样既保证了线程安全,也保证了性能和效率,但当有锁竞争时,就会升级加锁。成为自旋锁,也就是轻量级锁。如果此时这个锁竞争的还比较激烈,就会再次升级成为重量级锁。

通俗点就是没有锁竞争时,能不加尽量不加。当有冲突时,就抢先一步加锁。因为加锁是有一定开销的,这样做既保证了线程安全,也保证了性能和效率。

锁升级的过程,就是在性能和线程安全之间尽量权衡。(因为加锁之后,虽然线程是安全了,但同时效率也变慢了)。

(2)锁消除

一种编译器优化的手段。

即编译器会自动针对你当前写的加锁的代码做出判定,如果编译器觉得这个场景,不需要加锁,此时就会把你写的synchronized给优化掉。比如常见的两种可变数组StringBuffer和StringBuilder,

  • StringBurffer 带锁 synchronized 线程安全的
  • StringBuilder 不带锁

即如果在单个线程中使用StringBuffer,此时编译器就会自动的把synchronized给优化掉。因为只要加锁就会影响性能,效率。

(3)锁粗化

锁的粒度。

synchronized里头的代码越多,就认为锁的粒度越粗,代码越少,锁的粒度就越细。

粒度细的时候,能够并发执行的逻辑就更多,更加有利于利用多核CPU资源。

但是,如果粒度粗的锁,被反复加锁解锁,可能实际效果还不如力度粗的锁(涉及到锁的反复竞争)。

例如:

【多线程】锁策略_第4张图片

 

你可能感兴趣的:(习题总结,多线程安全,java,线程调度,多线程)