17-常见锁策略

目录

1.乐观锁&悲观锁

1.1.乐观锁

1.1.1.乐观锁定义

1.1.2.乐观锁实现——CAS

--->a.乐观锁是定理,CAS是具体实现。

--->b.两个线程进行CAS操作:

--->c.CAS底层实现-Unsafe-Atomic::cmpxchg

--->d.CAS应⽤-AtomicInteger 

--->e.漏洞:CAS会存在ABA的问题。

1.2.悲观锁

1.2.1.悲观锁定义

1.2.2.悲观锁应用

2.公平锁&非公平锁

2.1.公平锁

2.2.非公平锁

3.读写锁

3.1.读写锁

3.1.1.读写锁定义

3.1.2.读写锁实现

3.1.3.读写锁适用场景

3.1.4.代码实现

3.2.独占锁

3.3.共享锁

4.可重入锁&自旋锁

4.1.可重入锁

4.2.自旋锁


锁策略不仅仅是局限于 Java,任何和 "锁" 相关的话题,都可能会涉及到以下内容。这些特性主要是给锁的实现者来参考的。

1.乐观锁&悲观锁

1.1.乐观锁

1.1.1.乐观锁定义

乐观锁认为⼀般情况下不会出现锁冲突,所以只会在更新数据时才对锁冲突进⾏检测:

如果没有发⽣冲突,直接进⾏修改;

如果发⽣冲突,不做任何修改,然后把结果返回给⽤户,让⽤户⾃⾏决定处理。

1.1.2.乐观锁实现——CAS

--->a.乐观锁是定理,CAS是具体实现。

CAS(Compare And Swap)⽐较并替换(并没有锁的概念,性能高)。

执行流程:

CAS 中包含了三个操作单位:V(内存中的值)、A(预期的旧值)、B(新值);

⽐较 V 和 A 是否相等,如果相等则说明内存值未被其他线程修改过,是线程安全的,该线程将 V 的值更换成 B;

否则说明内存值已被其他线程修改,是线程不安全的,则不做任何改变,返回false。该线程就提示⽤户修改失败【理论层面】(或修改自身的值后再进行CAS【实践层面】),从⽽实现了 CAS 的机制。

PS:重试机制(循环CAS)

有很多文章说,CAS 操作失败后会一直重试直到成功,这种说法很不严谨。

  • 第一,CAS 本身并未实现失败后的处理机制,它只负责返回成功或失败的布尔值,后续由调用者自行处理。只不过我们最常用的处理方式是重试而已。
  • 第二,这句话很容易理解错,被理解成重新比较并交换。实际上失败的时候,原值已经被修改,如果不更改期望值,再怎么比较都会失败。而新值同样需要修改。
  • 所以正确的方法是,使用一个死循环进行 CAS 操作,成功了就结束循环返回,失败了就重新从内存读取值和计算新值,再调用 CAS。

所以CAS的一个问题是:循环时间长开销大。

如果CAS不成功,则会原地自旋,如果长时间自旋会给CPU带来非常大的执行开销。

即因为操作不是原子性一次执行完,故要验证此次执行和上次执行之间有没有被其他人动过手脚,从而判断是否安全,是否要进行修正,进行下一次执行。

--->b.两个线程进行CAS操作:

线程1:V=10,A=10,B=12。

线程2:V=10,A=10,B=11。

  • 线程1先得到CPU时间片,赋值得到A和B的值,还没有开始进行对比,时间片就用完了。
  • 线程2先将V=10和A=10进行对比,发现二者相等true,将V值更换为B,此时V=11。线程2执行完。
  • 线程1继续执行,对比V=11和A=10的值,二者不等false,不能直接进行更换操作。
  • 线程1会将A改为11,B=12不变。
  • 线程1对比V=11和A=11,二者相等true,将V值更换为B值,此时V=12。

线程1和线程2微观上先后执行,宏观上一起执行,最终将内存值V修改为符合预期的12,是线程安全的。

--->c.CAS底层实现-Unsafe-Atomic::cmpxchg

CAS 实现是借助 Unsafe 类(告诉程序员可能会产生不安全问题,它含有一些原生的方法,权限很大,可以直接操作物理内存,不推荐直接使用),Unsafe类中有一个compareAndSwapObject方法(CAS的具体实现),调⽤操作系统的 Atomic::cmpxchg(原⼦性汇编指令)。

--->d.CAS应⽤-AtomicInteger 

以Atomic前缀开头的类,其都是根据CAS实现的。

AtomicInteger类:

  • 底层使用unsafe类
  • 也有compareAndSet方法
  • getAndIncrement方法(相当于i++)
  • incrementAndGet(相当于++i)
  • getAndDecrement方法(相当于i--)
  • decrementAndGet(相当于--i)
import java.util.concurrent.atomic.AtomicInteger;

/**
 * CAS使用
 */
public class CASDemo1 {
    private static AtomicInteger atomicInteger = new AtomicInteger(0);//初始值
    private final static int MAX_COUNT = 1000000;

    public static void main(String[] args) throws InterruptedException {
        //++
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < MAX_COUNT; i++) {
                atomicInteger.getAndIncrement();//i++
            }
        });
        t1.start();

        //--
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < MAX_COUNT; i++) {
                atomicInteger.getAndDecrement();//i--
            }
        });
        t2.start();

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

        System.out.println("最终结果:" + atomicInteger.get());
    }
}

AtomicInteger虽然没有锁,但依旧可以避免简单的线程不安全问题的发生,就是因为其底层是根据CAS来实现的。

但也并非完全是线程安全的,会存在ABA问题。

--->e.漏洞:CAS会存在ABA的问题。

单线程没问题

  • 张三进行转账操作,原账户有100元,要-50元。
  • 第一次点击转账按钮(系统卡顿没有反应):-50元(V=100,A=100,B=50)。
  • 又第二次转账同样操作:-50元(V=100,A=100,B=50)。
  • 第二次先执行:判断V=A=100,改V=50。
  • 第一次又执行:判断此时V=50,A=100 -> 二者不等false,就不再次执行-50操作。
  • 单线程使用CAS还是没问题的,虽然点击了两次转账操作,但最终结果还是50正确的。

多线程会出现问题

  • 张三进行转账操作,原账户有100元,要-50元。
  • 第一次点击转账按钮(系统卡顿没有反应):-50元(V=100,A=100,B=50)。
  • 又第二次转账同样操作:-50元(V=100,A=100,B=50)。
  • 第二次先执行:判断V=A=100,改V=50。
  • 此时财务给张三发工资50元,余额V=50+50=100。
  • 第一次又执行:判断此时V=100,A=100 -> 二者相等true,再次执行-50操作,V=50。(无法判断出V虽然都是100,但已经是被修改过的了)
  • 最终得到结果:V=100-50+50-50=50;而正确的结果应该是V=100-50+50=100。
  • 多线程使用CAS会出现安全问题。

ABA问题代码演示:

import java.util.concurrent.atomic.AtomicInteger;

/**
 * ABA问题演示
 */
public class ABADemo1 {
    private static AtomicInteger money = new AtomicInteger(100);

    public static void main(String[] args) throws InterruptedException {
        //第一次点击转账按钮(-50)
        Thread t1 = new Thread(() -> {
            int old_money = money.get(); //先得到余额
            //执行花费2s
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //对比并替换
            money.compareAndSet(old_money, old_money - 50); //预期的旧值:100;新值:50
        });
        t1.start();

        //第二次点击转账按钮(-50)不小心点击的,因为第一次点击之后没反应,所以第二次又点了一次
        Thread t2 = new Thread(() -> {
            int old_money = money.get();
            //对比并替换
            money.compareAndSet(old_money, old_money - 50);//预期的旧值:100;新值:50
        });
        t2.start();

        //给账户+50元
        Thread t3 = new Thread(() -> {
            //执行花费1s,这样线程t2就执行完毕
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            int old_money = money.get();
            money.compareAndSet(old_money, old_money + 50);
        });
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println("最终账户余额:" + money);
        //执行顺序:2,3,1
    }
}

ABA解决方案AtomicStampedReference引入版本号:本次操作之后让版本号+1,执行时判断版本号的值,即可解决ABA问题。

import java.util.concurrent.atomic.AtomicStampedReference;

/**
 * ABA问题演示
 */
public class ABADemo2 {
    private static AtomicStampedReference<Integer> money = new AtomicStampedReference<>(100,0); //100是预先的余额,0是预先的版本号,也可设置为1

    public static void main(String[] args) throws InterruptedException {
        //第一次点击转账按钮(-50)
        Thread t1 = new Thread(() -> {
            int old_money = money.getReference();//先得到余额
            int version = money.getStamp();//得到版本号
            //执行花费2s
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //对比并替换
            money.compareAndSet(old_money, old_money - 50, version, version + 1);//预期地旧值:100;新值:50;预期的旧的版本号;新的版本号
        });
        t1.start();

        //第二次点击转账按钮(-50)不小心点击的,因为第一次点击之后没反应,所以第二次又点了一次
        Thread t2 = new Thread(() -> {
            int old_money = money.getReference();//先得到余额
            int version = money.getStamp();//得到版本号
            //对比并替换
            money.compareAndSet(old_money, old_money - 50, version, version + 1);
        });
        t2.start();

        //给账户+50元
        Thread t3 = new Thread(() -> {
            //执行花费1s,这样线程t2就执行完毕
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            int old_money = money.getReference();//得到余额
            int version = money.getStamp();//得到版本号
            money.compareAndSet(old_money, old_money + 50, version, version + 1);
        });
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println("最终账户余额:" + money.getReference());
        //执行顺序:2,3,1
    }
}

1.2.悲观锁

1.2.1.悲观锁定义

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

1.2.2.悲观锁应用

synchronized、Lock 都是悲观锁。

2.公平锁&非公平锁

2.1.公平锁

所有任务来了之后先排队,线程空闲之后去任务队列按顺序执⾏最早任务:

ReentrantLock lock= new ReentrantLock(true)。

性能低。

2.2.非公平锁

抢占式执⾏,有⼀些先来的任务还在排队,刚好释放锁的时候新来了⼀个任务,此时并不会通知任务队列来执⾏任务,⽽是执⾏新来的任务:

ReentrantLock lock = new ReentrantLock(false)。

如果构造函数不传递参数,则默认是⾮公平锁。默认锁都是非公平锁。

性能高。

3.读写锁

3.1.读写锁

3.1.1.读写锁定义

读写锁(Readers-Writer Lock)顾名思义是将⼀把锁分为两部分:读锁和写锁。

其中读锁允许多个线程同时获得,因为读操作本身是线程安全的;

写锁则是互斥锁,不允许多个线程同时获得(写锁)。

并且读操作和写操作也是互斥的,这样可以保证读到的数据是最终的数据,而不是写到一半的数据。

读写锁的特点是:读读不互斥、读写互斥、写写互斥

3.1.2.读写锁实现

Java 标准库提供了 ReentrantReadWriteLock 类,实现了读写锁。

可传参设置公平锁/非公平锁:

不传参->默认是非公平锁;

传参false->非公平锁;传参true->公平锁。

  • ReentrantReadWriteLock.ReadLock 类表示⼀个读锁,这个对象提供了 lock / unlock ⽅法进⾏加锁/解锁。
  • ReentrantReadWriteLock.WriteLock 类表示⼀个写锁,这个对象也提供了 lock / unlock⽅法进⾏加锁/解锁。

3.1.3.读写锁适用场景

注意

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

读写锁特别适合于 "频繁读,不频繁写" 的场景中(这样的场景其实也是⾮常⼴泛存在的)。

比如教务系统:

每节课⽼师都要使⽤教务系统点名,点名就需要查看班级的同学列表(读操作)。这个操作可能要每周执⾏好⼏次。

⽽什么时候修改同学列表呢(写操作)? 就新同学加⼊的时候,可能⼀个⽉都不必改⼀次。

再⽐如,同学们使⽤教务系统查看作业(读操作),⼀个班级的同学很多,读操作⼀天就要进⾏⼏⼗次上百次。

但是这⼀节课的作业,⽼师只是布置了⼀次(写操作)。

3.1.4.代码实现

import java.time.LocalDateTime;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * 演示读写锁的使用
 */
public class ReadWriteLockDemo1 {
    public static void main(String[] args) {
        //创建读写锁
        final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); //不传参默认是非公平锁;传false->非公平锁;传true->公平锁。
        //创建读锁
        final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
        //创建写锁
        final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();

        //线程池
        ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));

        //启动新线程执行任务(读操作1)
        executor.submit(() -> {
            //加锁操作
            readLock.lock();
            try{
                //执行业务逻辑
                System.out.println("执行读锁1:" + LocalDateTime.now());
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                //释放锁操作
                readLock.unlock();
            }
        });

        //启动新线程执行任务(读操作2)
        executor.submit(() -> {
            readLock.lock();
            try{
                System.out.println("执行读锁2:" + LocalDateTime.now());
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                readLock.unlock();
            }
        });

        //启动新线程执行任务(写操作1)
        executor.submit(() -> {
            writeLock.lock();
            try{
                System.out.println("执行写锁1:" + LocalDateTime.now());
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException exception) {
                exception.printStackTrace();
            } finally {
                writeLock.unlock();
            }
        });

        //启动新线程执行任务(写操作2)
        executor.submit(() -> {
            writeLock.lock();
            try{
                System.out.println("执行写锁2:" + LocalDateTime.now());
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException exception) {
                exception.printStackTrace();
            } finally {
                writeLock.unlock();
            }
        });
    }
}

3.2.独占锁

独占锁是指任何时候都只有⼀个线程能执⾏资源操作。 也叫互斥锁。

synchronized、Lock。

3.3.共享锁

共享锁指定是可以同时被多个线程读取,但只能被⼀个线程修改。

⽐如 Java 中的ReentrantReadWriteLock 就是共享锁的实现⽅式,它允许⼀个线程进⾏写操作,允许多个线程读操作。

4.可重入锁&自旋锁

4.1.可重入锁

可重⼊锁指的是该线程获取了该锁之后,可以⽆限次地进⼊该锁锁住的代码。

public class ThreadDemo21 {
    public static void main(String[] args) {
        synchronized (ThreadDemo21.class) {
            System.out.println("线程执行进入了方法");
            synchronized (ThreadDemo21.class) {
                System.out.println("线程执行又进入了方法");
                synchronized (ThreadDemo21.class) {
                    System.out.println("线程执行又又进入了方法");
                }
            }
        }
    }
}

4.2.自旋锁

⾃旋锁是指尝试获取锁的线程不会⽴即阻塞,⽽是采⽤循环的⽅式去尝试获取锁。

这样的好处是减少线程上下⽂切换的消耗,缺点是循环会消耗 CPU。

//伪代码
while(!尝试获取锁 < 15) {

}

synchronized是自适应自旋锁,自旋次数是不固定的,会自我调节,不同的JVM设置的自旋次数自适应也是不同的:

  • 第一次没谱,会设置默认的自旋次数值,以后当达到上次设置的自旋次数时获取到锁,会适当缩短自旋锁次数;当超过自旋次数还没有得到锁,会适当延长自旋次数。
  • 第一次会设置默认的自旋次数值,以后当达到上次设置的自旋次数时获取到锁,自旋锁次数不变,认为是合理的;当超过自旋次数还没有得到锁,会适当缩小自旋次数,因为JVM认为上次在循环了那么多次都没有得到锁,那么在下一次大概率通过自旋也不能得到锁。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值