【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++;
  }
}//锁的粒度大

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

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


相关文章
|
16天前
|
并行计算 安全 Java
Python GIL(全局解释器锁)机制对多线程性能影响的深度分析
在Python开发中,GIL(全局解释器锁)一直备受关注。本文基于CPython解释器,探讨GIL的技术本质及其对程序性能的影响。GIL确保同一时刻只有一个线程执行代码,以保护内存管理的安全性,但也限制了多线程并行计算的效率。文章分析了GIL的必要性、局限性,并介绍了多进程、异步编程等替代方案。尽管Python 3.13计划移除GIL,但该特性至少要到2028年才会默认禁用,因此理解GIL仍至关重要。
86 16
Python GIL(全局解释器锁)机制对多线程性能影响的深度分析
|
25天前
|
算法 安全 Java
Java线程调度揭秘:从算法到策略,让你面试稳赢!
在社招面试中,关于线程调度和同步的相关问题常常让人感到棘手。今天,我们将深入解析Java中的线程调度算法、调度策略,探讨线程调度器、时间分片的工作原理,并带你了解常见的线程同步方法。让我们一起破解这些面试难题,提升你的Java并发编程技能!
65 16
|
2月前
|
Java
【JavaEE】——多线程常用类
Callable的call方法,FutureTask类,ReentrantLock可重入锁和对比,Semaphore信号量(PV操作)CountDownLatch锁存器,
|
2月前
|
Java 关系型数据库 MySQL
【JavaEE“多线程进阶”】——各种“锁”大总结
乐/悲观锁,轻/重量级锁,自旋锁,挂起等待锁,普通互斥锁,读写锁,公不公平锁,可不可重入锁,synchronized加锁三阶段过程,锁消除,锁粗化
|
2月前
|
Java Go 调度
【JavaEE】——线程池大总结
线程数量问题解决方式,代码实现线程池,ThreadPoolExecutor(核心构造方法),参数的解释(面试:拒绝策略),Executors,工厂模式,工厂类
|
2月前
|
缓存 安全 Java
【JavaEE】——单例模式引起的多线程安全问题:“饿汉/懒汉”模式,及解决思路和方法(面试高频)
单例模式下,“饿汉模式”,“懒汉模式”,单例模式下引起的线程安全问题,解锁思路和解决方法
|
2月前
|
Java 调度
|
2月前
|
Java 调度
【JavaEE】——线程的安全问题和解决方式
【JavaEE】——线程的安全问题和解决方式。为什么多线程运行会有安全问题,解决线程安全问题的思路,synchronized关键字的运用,加锁机制,“锁竞争”,几个变式
|
2月前
|
Java API 调度
【JavaEE】——多线程(join阻塞,计算,引用,状态)
【JavaEE】——多线程,join,sleep引起的线程阻塞,多线程提升计算效率,如何获取线程的引用和状态
|
2月前
|
Java 程序员 调度
【JavaEE】线程创建和终止,Thread类方法,变量捕获(7000字长文)
创建线程的五种方式,Thread常见方法(守护进程.setDaemon() ,isAlive),start和run方法的区别,如何提前终止一个线程,标志位,isinterrupted,变量捕获

热门文章

最新文章

相关实验场景

更多