java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java常见锁策略

Java常见的锁策略图文详解(附实例代码)

作者:Jul1en_

Java中的锁方法是指通过特定的机制来确保多线程环境下对共享资源的互斥访问,以避免数据不一致和竞态条件,这篇文章主要介绍了Java常见锁策略的相关资料,文中通过代码介绍的非常详细,需要的朋友可以参考下

常见的锁策略

乐观锁 & 悲观锁

加锁的时候预测这个锁出现竞争性的可能性大还是小?

预测这个锁出现竞争可能性小 —— 乐观锁

预测这个锁出现竞争可能性大 —— 悲观锁

轻量级锁 & 重量级锁

加锁的开销小 —— 轻量级锁

加锁的开销大 —— 重量级锁

自旋锁 & 挂起等待锁

遇到锁冲突,先不着急阻塞,而是尝试重新得到锁。这个操作不涉及内核态,仅在用户态方面进行,所以更加轻量 —— 自旋锁

遇到锁冲突,阻塞等待,等到合适的时机再拿锁。涉及到系统的内部调度,开销大 —— 挂起等待锁

公平锁 & 非公平锁

JVM约定了“先来后到” 是公平锁(先请求得到锁的线程会得到锁)

“公平竞争,各凭本事”是不公平锁(解锁后,多个线程同时竞争一把锁)

可重入锁 & 不可重入锁

一个线程,一把锁,同时加锁两次

如果没死锁:可重入锁;死锁了:不可重入锁

C++的std :: mutex 是不可重入锁

可重入锁也称“可递归锁”

synchronized是可重入锁,以第一次加锁为真,第二次加锁跳过

普通互斥锁 & 读写锁

普通互斥锁有

读写锁有

如果代码中线程进行读的操作,那可以使用“读锁”,写的操作使用“写锁”

读锁与读锁之间不存在互斥 —— 读的过程数据不会发生改变,只是读

读锁与写锁之间存在互斥 —— 读的过程中可能会被“写”修改

写锁与写锁之间存在互斥 —— A写的时候肯定不能被B修改

synchronized锁

synchronized锁是普通互斥锁,是可重入锁,是非公平锁,有着自适应的特点,根据内部锁竞争的激烈程度,自动调整内部策略,感知不到,干预不了,但我们需要知道内部策略细节

锁升级

锁消除

针对synchronized的一种编译器优化,在保证逻辑不变的情况下,如果编译器确定你写的代码不需要锁,但你手动加了锁,会尝试把锁去掉。

但这个优化十分保守,没有十拿九稳的情况下不会触发,我们也不能依赖编译器优化这个机制来写代码

锁粗化

关联到锁的粒度,编译器会根据实际情况来操作

如果锁的粒度小,证明加锁解锁中间的逻辑少,虽然会留出时间给其他的线程来得到锁,但也增加了线程竞争的次数,也会增加阻塞的时间。

锁的粒度虽然大,但是中间执行的逻辑时间长,没有反复加锁解锁的操作,其他线程也只能竞争一次。

CAS

Compare And Swap

解决线程安全,加锁是一种普遍的策略,CAS是解决线程安全问题的另一种思路

顾名思义 比较与交换 —— 比较一个内存与一个寄存器内部的值,如果他两的值相同,内存会与寄存器另外一个值进行交换,交换后更关心内存更新之后的值,就可以实现“锁”的功能

这是CAS的执行逻辑

上述针对CAS的执行逻辑,能看出它并不是函数,而是一条CPU指令,而且是原子的,那JVM如何运行的呢?

  1. CPU提供了CAS的执行指令
  2. 操作系统对CAS指令进行了封装并提供了API,通过API可以调用CAS机制
  3. JVM就可以通过操作系统调用API
  4. 我们的代码就可以使用CAS
  5. Java对CAS进一步封装,Java不建议你用CAS,但内部像==原子类==、自旋锁、synchronized,ConcurrentHashMap都使用了CAS

这又引申出了**原子类**

原子类

内部没有加锁,但基于CAS实现了,所以不加锁也能保证线程安全

可以实例化原子类的整数/字符…,并设定初始化的值

方法与注释中的操作是一一对应的,举例getAndIncrement 是先得到旧值,再Increment(+1)

自旋锁

synchronized内部的自旋锁,就是基于CAS实现,这就是为什么自旋锁轻量的原因(“无锁”)

ABA问题

CAS的循环检测,判定是否有其他线程穿插修改执行的依据是循环判定是检测的值是否有变化,但是这种检测方式是不严谨的,没有变化不能代表没有被修改过,可能某时刻的值是A,在下一时刻被修改了B,然后在第二次检测之前又被修改回了A,这就是ABA问题

大部分时候ABA问题不会引起bug,但在某些极端的情况下是会的,就像买新手机一样,你买的手机是“翻新机”,但翻新机不代表一定就有问题,只是说翻新机出现问题的概率比全新机更大的~

ABA的核心是:
在通过A的值相同判定是否有插队,由于次数的修改有加也有减,就可能会出现ABA的问题,那么有一些解决方案:限制此处的操作(只能加或者只能减)、或者引入“版本号”概念,每次修改都让“版本号”+1,再次使用判定的时候就不是用值来判定了,而是看版本号

那我们可以引申出以下问题🤔

  1. CAS是什么?
  2. CAS有什么应用场景(CAS如何实现锁)?
  3. CAS的ABA问题是怎么样的

JUC包中常见的类

JUC——Java.util.Concurrent

Callable接口

是泛型接口,描述一个任务,唯一方法call()

类似与Runnable,但与Runnable的void run() 不同的是Callable的 V call() throws Exception 有返回值,通常Callable任务会被交给FutureTaskExecutorService.submit()来执行

		Callable<Integer> callable = new Callable<Integer>() {
     		int sum = 0;
            @Override
            public Integer call() throws Exception {
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);

FutureTask & Callable 的联系

  1. Callable是一个接口,类似与Runnable,但有返回值,并且能抛出受检异常

    • 使用场景:需要执行一个有返回值的任务时
  2. FutureTask是一个类

    • 实现了RunnableFuture 接口

      public class FutureTask<V> implements RunnableFuture<V>

    • 而RunnableFuture 又继承了Runnable 和 Future

      public interface RunnableFuture<V> extends Runnable, Future<V> { void run(); }

    • 所以FutureTask既是一个任务Runnable,也是一个容器Future

    • 它内部持有Callable,并在run()方法中调用Callable的call()方法

他们的call()/ run()和线程的run()关系
  1. Callable.call()

    • 是用户定义的逻辑,可以返回,可以抛出受检异常
    • 不能直接交给Thread运行,因为它不是RunnableThread只能运行Runnable
  2. FutureTask.run()

    • Runnable的实现
    • 内部会调用Callable.call(),并把结果存起来,通过.get()得到
  3. Thread.run()

    • Thread本身的run()是空的,当你传入一个Runnable后它才会调用Runnable.run()
    • 如果传入的是FutureTask,则会调用FutureTask.run(),再通过FutureTask.run()来调用Callable.call()

执行链条:

  1. Thread.run() -> Runnable.run()
  2. Thread.run() -> FutureTask.run() -> Callable.call()

ExecutorService & Callable

ExecutorService.submit(Callable)不会直接调用Callable.call(),而是间接调用

  1. newTaskFor(task)

    • 会把 Callable 包装成一个 FutureTaskRunnableFuture 的实现类)。
    • 这样它既是一个 Runnable(能被线程池执行),又是一个 Future(能保存结果)。
  2. execute(ftask)

    • 线程池把 FutureTask 当作 Runnable,交给工作线程运行。
    • 线程执行时,会调用 FutureTask.run()
  3. FutureTask.run()

    • 内部调用 callable.call() 来真正执行你的逻辑,并把结果存起来。

执行链条:

ExecutorService.submit(Callable)

  1. -> newTaskFor(Callable) (Callable -> FutureTask)
  2. -> execute(FutureTask)
  3. -> Worker(Thread) 执行
  4. -> FutureTask.run()
  5. -> Callable.call()

对比Runnable,用Callable的优势

  1. 有返回值

    • Runnable.run()没有返回值
    • Callable.call()有返回值,结合Future/FutureTask使用,就能拿到异步计算的结果

    优点:适合计算类任务,例如“统计一批文件的大小”“并行计算求和”等

  2. 可以抛出受检异常

    • Runnable不能抛出受检异常,有异常只能自己捕获或者包装成RuntimeException
    • Callable可以抛出受检异常,并保存到Future中,调用get()再抛出

    优点:让异常处理逻辑更加自然,避免任务里硬编码try-catch

  3. 和并发框架集成好

    • ExecutorService.submit(Callable task)会返回一个Future,可以用来

      • 获取结果:future.get()
      • 取消任务:future.cancel(true)
      • 检查状态:future.isDone()

    优点:和Runnable相比,更合适在生产级并发框架应用(线程池,ForkJoinPool)

  4. 支持函数式编程(lambda)

    • Callable是函数式接口,支持lambda表达式

      Callable<Integer> task = () -> 42;

    优点:代码更加简洁美观

ReentrantLock可重入锁

一个可重入互斥lock具有与使用synchronized方法和语句访问的隐式监视锁相同的基本行为和语义,但具有扩展功能。

在上古时期的时候还没有synchronized,当时java的加锁就是用的ReentrantLock

  1. 支持trylock

    尝试加锁,如果加锁失败,就直接返回(放弃),也支持指定超时时间

  2. 支持公平锁

  3. 等待通知机制

    • synchronized.wait()搭配notify(),只支持随机唤醒或唤醒全部
    • ReentrantLock搭配Condition类,唤醒功能更加丰富,能指定唤醒

与synchronized区别

相同点:

  1. synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁

不同点:

  1. 用法不同:synchronized 可以用来修饰普通方法、静态方法和代码块;ReentrantLock 只能用于代码块;
  2. 获取和释放锁的机制不同:进入synchronized 块自动加锁和执行完后自动释放锁; ReentrantLock 需要显示的手动加锁和释放锁;
  3. 锁类型不同:synchronized 是非公平锁; ReentrantLock 默认为非公平锁,也可以手动指定为公平锁;
  4. 响应中断不同:synchronized 不能响应中断;ReentrantLock 可以响应中断,可用于解决死锁的问题;
  5. 底层实现不同:synchronized 是 JVM 层面通过监视器实现的;ReentrantLock 是基于 AQS 实现的。

‍总结

到此这篇关于Java常见锁策略的文章就介绍到这了,更多相关Java常见锁策略内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文