【JavaEE多线程】掌握锁策略与预防死锁

简介: 【JavaEE多线程】掌握锁策略与预防死锁

常见的锁策略

锁策略就属于是实现锁的人要理解的。

以下指的不是某个具体的锁,而是描述锁的特性,描述的是“一类锁”

乐观锁 vs 悲观锁

乐观锁:预测该场景中,不太会出现锁冲突的情况。(后续做的工作会更少)

悲观锁:预测该场景中,非常容易出现锁冲突。(后续做的工作会更多)

锁冲突:两个线程尝试获取一把锁,一个线程获取成功,另一个线程阻塞等待

锁冲突的概率大还是小,对后续的工作是有一定影响的。

synchronized 初始使用乐观锁策略.当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略

重量级锁 VS 轻量级锁

重量级锁:加锁开销比较大(花的时间多、系统资源占的多),一个悲观锁很可能是个重量级锁(不绝对)

轻量级锁:加锁开销比较小(花的时间少、系统资源占的少),一个乐观锁很可能是个轻量级锁(不绝对)

synchronized 开始时是一个轻量级锁,如果锁冲突严重,就可能变成重量级锁

悲观乐观 是在加锁之前对锁冲突概率的预测,决定工作的多少

重量轻量 是在加锁之后考量实际的锁的开销

自旋锁 VS 挂起等待锁

自旋锁是轻量级锁的一种典型实现,在用户态下,通过自旋的方式(while循环),实现类似于加锁的效果

这种锁,会消耗一定CPU资源,但是可以做到最快速度拿到锁

挂起等待锁是重量级锁的一种典型实现,通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,会牵扯到内核对于线程的调度,使冲突的线程出现挂起(阻塞等待)

这种锁,消耗的CPU资源是更少的,但也无法保证第一时间拿到锁

synchronized中的轻量级锁策略大概率就是通过自旋锁方式实现的

读写锁 VS 互斥锁

读写锁,把读操作加锁和写操作加锁分开了(一个事实:多线程同时去读一个变量,不涉及线程安全问题)

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

读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁

  • ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.

其中,

  • 读加锁和读加锁之间, 不互斥.
  • 写加锁和写加锁之间, 互斥.
  • 读加锁和写加锁之间, 互斥.

注意, 只要是涉及到 “互斥”, 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多久了.

因此尽可能减少 “互斥” 的机会, 就是提高效率的重要途径.

公平锁 VS 非公平锁

此处定义:公平锁遵循先来后到

非公平锁:看似概率均等,实际上是不公平的,每个线程阻塞的时间不同

注意:

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

synchronized是非公平锁

可重入锁 VS 不可重入锁

如果一个线程,针对一把锁,连续加锁两次,会出现死锁,就是不可重入锁;不会出现死锁,就是可重入锁。即允许同一个线程多次获取同一把锁

Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。

而 Linux 系统提供的 mutex 是不可重入锁.

可重入锁是锁记录了当前哪个线程持有了锁

死锁

死锁是什么

死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

举个例子理解死锁

男神和女神一起去饺子馆吃饺子. 吃饺子需要酱油和醋.

男神抄起了酱油瓶, 女神抄起了醋瓶.

男神: 你先把醋瓶给我, 我用完了就把酱油瓶给你.

女神: 你先把酱油瓶给我, 我用完了就把醋瓶给你.

如果这俩人彼此之间互不相让, 就构成了死锁.

酱油和醋相当于是两把锁, 这两个人就是两个线程.

死锁的三种典型情况:

  1. 一个线程一把锁,但是是不可重入锁,该线程针对这个锁连续加锁两次,就会出现死锁
  2. 两个线程两把锁,这两个线程先分别获取到一把锁,然后再同时尝试获取对方的锁
  3. N个线程M把锁,为了进一步阐述死锁的形成, 很多资料上也会谈论到 “哲学家就餐问题”.
  • 有个桌子,围着一圈哲学家,桌子中间放着一盘意大利面,每个哲学家两两之间, 放着一根筷子.

  • 哲学家吃面条的时候就会拿起左右两边的筷子(先拿起左边,再拿起右边).

  • 如果哲学家发现筷子拿不起来了(被别人占用了),就会阻塞等待.

  • [关键点在这] 假设同一时刻, 五个哲学家同时拿起左手边的筷子, 然后再尝试拿右手的筷子, 就会发现右手的筷子都被占用了. 由于哲学家们互不相让, 这个时候就形成了 死锁


死锁是严重的BUG!导致一个程序的线程池卡死,无法正常工作

如何避免死锁

  • 死锁的四个必要条件:(缺一不可,能破坏其中任意一个条件,就可以避免出现死锁)
  1. 互斥使用,一个线程获取到一把锁以后,别的线程不能获取到这个锁。实际使用的锁,一般都是互斥的(锁的基本特性
  2. 不可抢占,锁只能是被持有者主动释放,而不能是被其他线程直接抢走。也是锁的基本特性
  3. 请求和保持,一个线程去尝试获取多把锁,在获取第二把锁的过程中,会保持对第一把锁的获取状态。取决于代码结构**(很可能影响到需求)**
  4. 循环等待,t1尝试获取locker2,需要t2执行完然后释放locker2,t2尝试获取locker1,需要t1执行完然后释放locker1。取决于代码结构**(解决死锁问题的最关键要点)**

当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。

如何具体解决死锁问题,实际方法很多(银行家算法,可以解决,但不太接地气)

其中最容易破坏的就是 “循环等待”.

针对锁进行编号,并且规定加锁的顺序

比如,约定,每个线程如果要获取多把锁必须先获取编号小的锁后获取编号大的锁。只要所有线程加锁的顺序,都严格遵守上述顺序,就一定不会出现循环等待!

synchronized具体采用了哪些锁策略呢?

  1. 既是悲观锁也是乐观锁
  2. 既是重量级锁也是轻量级锁
  3. 重量级锁部分是基于系统的互斥锁实现的,轻量级锁部分是基于自旋锁实现的
  4. 是非公平锁(不会遵循先来后到,锁释放之后,哪个线程拿到锁,各凭本事)
  5. 是可重入锁(内部会记录哪个线程拿到了锁,记录引用次数)
  6. 不是读写锁

以下是synchronized内部实现策略(内部原理):代码中写了个synchronized之后,这里可能产生一系列的“自适应的过程”,锁升级(锁膨胀)

依次从 无锁->偏向锁->轻量级锁->重量级锁

  1. 偏向锁不是真的加锁而只是做了一个"标记",如果有别的线程来竞争锁了,才会真的加如果没有别的线程竞争,就自始至终都不会真的加锁了。加锁本身,有一定开销。能不加,就不加,非得是有人来竞争了,才会真的加锁~

偏向锁在没有其他人竞争的时候,就仅仅是一个简单的标记(非常轻量),一旦有别的线程尝试进行加锁,就会立即把偏向锁,升级 成真正的加锁状态,让别人只能阻塞等待

  1. 轻量级锁,sychronized通过自旋锁的方式来实现轻量级锁。我这边把锁占据了,另一个线程就会按照自旋的方式,来反复查询当前的锁的状态是不是被释放了。但是,后续,如果竞争这把锁的线程越来越多了(锁冲突更激烈了),从轻量级锁升级成重量级锁
  2. 锁消除:编译器会智能的判定,当前这个代码是否需要加锁,如果你写了加锁,但实际上没有必要加锁,就会把加锁操作自动删除掉
  3. 锁粗化:关于:“锁的粒度”,如果加锁操作里包含的实际要执行的代码越多,就认为锁的粒度越大
for(...){
  synchronized(this){
    count++;
  }
}//锁的粒度小


synchronized(this){
  for(...){
    count++;
  }
}//锁的粒度大

有的时候希望锁的粒度小比较好,并发程度更高

有的时候,也希望锁的粒度大比较好(因为加锁解锁本身也有开销)


相关文章
|
23天前
|
Java
线程池内部机制:线程的保活与回收策略
【10月更文挑战第24天】 线程池是现代并发编程中管理线程资源的一种高效机制。它不仅能够复用线程,减少创建和销毁线程的开销,还能有效控制并发线程的数量,提高系统资源的利用率。本文将深入探讨线程池中线程的保活和回收机制,帮助你更好地理解和使用线程池。
48 2
|
1月前
|
调度 Android开发 开发者
构建高效Android应用:探究Kotlin多线程优化策略
【10月更文挑战第11天】本文探讨了如何在Kotlin中实现高效的多线程方案,特别是在Android应用开发中。通过介绍Kotlin协程的基础知识、异步数据加载的实际案例,以及合理使用不同调度器的方法,帮助开发者提升应用性能和用户体验。
47 4
|
1月前
|
Java 数据库连接 数据库
不同业务使用同一个线程池发生死锁的技术探讨
【10月更文挑战第6天】在并发编程中,线程池是一种常用的优化手段,用于管理和复用线程资源,减少线程的创建和销毁开销。然而,当多个不同业务场景共用同一个线程池时,可能会引发一系列并发问题,其中死锁就是最为严重的一种。本文将深入探讨不同业务使用同一个线程池发生死锁的原因、影响及解决方案,旨在帮助开发者避免此类陷阱,提升系统的稳定性和可靠性。
49 5
|
1月前
|
Java 应用服务中间件 测试技术
Java21虚拟线程:我的锁去哪儿了?
【10月更文挑战第8天】
38 0
|
1月前
|
安全 调度 数据安全/隐私保护
iOS线程锁
iOS线程锁
27 0
|
1月前
|
Java API
【多线程】乐观/悲观锁、重量级/轻量级锁、挂起等待/自旋锁、公平/非公锁、可重入/不可重入锁、读写锁
【多线程】乐观/悲观锁、重量级/轻量级锁、挂起等待/自旋锁、公平/非公锁、可重入/不可重入锁、读写锁
32 0
|
1月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
44 1
C++ 多线程之初识多线程
|
27天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
19 3
|
27天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
17 2
|
27天前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
28 2