Java并发

死锁

死锁4个条件

程序出现死锁,是因为在多线程环境里面两个或两个以上的线程同时满足

互斥条件、请求和保持条件、不可抢占条件、循环等待条件。

互斥条件:共享资源x和y只能被一个线程占用

请求和保持条件:线程t1已经获取共享资源x,在等待共享资源y的时候,不释放共享资源x

不可抢占条件:其他线程不能强行抢占线程t1占有的资源

循环等待条件,线程t1等到线程t2占有的资源,线程t2等待线程t1占有的资源,形成循环等待

死锁如何解决

1. 避免死锁

通过设计合理的资源请求策略,避免死锁的发生。

  • 破坏循环等待条件:
    • 规定线程必须按照一定的顺序申请资源(即资源的排序),例如,所有线程必须先申请资源 A,再申请资源 B。这样就能避免形成循环等待。
    • 资源顺序法: 每种资源指定一个唯一的编号,所有线程必须按照编号顺序申请资源。如果一个线程已经持有了编号小的资源,其他线程就只能按编号顺序申请更大的资源。
  • 破坏请求与保持条件:
    • 可以要求线程在申请任何资源时,必须先释放它已经持有的资源,或者线程必须一次性申请所有需要的资源,避免在持有资源时等待其他资源。
    • 例如,如果线程需要多个资源,可以一次性申请所有资源,成功则继续执行,失败则释放所有资源并重试。
  • 破坏互斥条件:
    • 尽量避免使用互斥资源,或者尽量使资源能够共享,避免单线程持有独占资源。
    • 这种方法通常较难实现,因为许多资源本身就要求是互斥的(例如数据库记录锁定)。
  • 破坏不剥夺条件:
    • 如果线程持有的资源在等待其他资源时,可以强制剥夺资源(即操作系统可以中断线程并分配资源给其他线程)。但在实际应用中,剥夺资源可能会导致其他问题,因此这种方法通常不被推荐。

2. 死锁检测与恢复

当无法避免死锁时,可以通过死锁检测算法来检测死锁的发生,并采取适当的恢复措施。

  • 死锁检测:
    • 在系统中维护一个资源分配图(Resource Allocation Graph)。在图中,节点表示进程或资源,边表示进程与资源的请求或持有关系。如果检测到图中存在环路,就说明发生了死锁。
    • 另一种方式是通过检查资源分配情况,发现进程都在等待其他进程释放资源,从而判定是否发生了死锁。
  • 死锁恢复:
    • 终止进程: 如果发生死锁,可以选择终止某些进程来打破死锁的循环。例如,选择终止死锁中的一个或多个进程,从而释放资源,打破等待链。
    • 回滚进程: 如果使用的是数据库或其他可回滚的系统,可以选择回滚某些进程,将其回到一个安全的状态,从而释放资源并重新启动执行。
    • 资源剥夺: 通过剥夺某些进程的资源,并将这些资源分配给其他进程,从而打破死锁。但需要确保剥夺资源的方式不会引入新的问题(如数据不一致等)。

3. 使用时间戳与超时机制

另一种方法是使用时间戳和超时机制,设置资源请求的超时时间。当进程等待的资源超过一定时间仍未获得时,就主动放弃资源请求或抛出异常,这样可以避免死锁情况的长期等待。

  • 时间戳机制: 为每个资源请求打上时间戳,按照请求时间的先后顺序进行调度,避免多个线程长时间互相等待。
  • 超时机制: 设置请求资源的超时时间,如果在规定时间内未能成功获取资源,则取消请求并重新尝试。

4. 使用事务与锁的粒度控制

在数据库或类似系统中,使用事务控制可以避免死锁:

  • 最小化锁的持有时间: 尽量缩短锁的持有时间,避免长时间占用资源。
  • 合理的锁粒度: 避免过度锁定资源,选择合适的锁粒度,避免因锁定过多资源而增加死锁发生的概率。
  • 避免嵌套锁: 如果多个线程需要对多个资源加锁,尽量避免嵌套加锁(即线程获取锁的顺序是动态的),应该采用固定的加锁顺序。

5. 锁的替代方案

使用更高级的锁策略和并发控制工具,减少死锁的可能性。例如:

  • 读写锁(Read-Write Lock): 可以允许多个线程并行读取共享资源,而写操作仍然需要排他锁。通过提高并发性,减少竞争,可以降低死锁的概率。
  • 无锁编程(Lock-free): 使用乐观锁(如CAS)等无锁机制避免死锁问题。这种方法通过使用原子操作和其他无锁数据结构来减少对资源的竞争,从而避免死锁。

ThreadLocal 的工作原理是什么?如何避免其引发的内存泄漏问题?在线程池中使用 ThreadLocal 时需要注意哪些事项?

  • 工作原理:每个线程都有一个独立的ThreadLocalMap,用于存储线程本地变量。ThreadLocal通过set和get方法操作线程的ThreadLocalMap,确保变量在线程间隔离。
  • 避免内存泄漏:
    • 清理变量:在使用完ThreadLocal后,调用remove方法清除变量。
    • 避免强引用:避免将大对象存储在ThreadLocal中,减少内存占用。
  • 线程池注意事项
  • 显式清理:线程池中的线程是复用的,必须在任务结束时显式调用remove方法清理ThreadLocal。
  • 数据污染:如果未清理,ThreadLocal变量可能会残留到下一个任务中,导致数据污染。

乐观锁和悲观锁的区别

乐观锁

核心思想:假设在并发访问中,不会发生冲突,因此在进行操作时不会立即加锁。它的工作方式是:在操作时,不加锁,先执行操作,之后通过某种方式(如版本号或CAS)检查是否有其他线程修改了共享资源。如果没有冲突,则提交修改;如果有冲突,则回滚或重试。
常见实现方式:

  • CAS(Compare and Swap):一种常用的无锁操作,通过比较内存中的值与预期值是否相等,来决定是否进行更新。
  • 版本号控制:每次修改资源时,更新版本号,在提交更新时检查版本号是否一致。

悲观锁

核心思想:假设在并发访问中会发生冲突,因此在操作前就进行加锁,确保只有一个线程能够访问和修改资源。其他线程必须等待锁释放后才能访问共享资源。悲观锁会阻塞其他线程,直到当前线程完成操作并释放锁。
常见实现方式:

  • 互斥锁(Mutex):如ReentrantLock、synchronized等,可以确保某个线程对共享资源的独占访问。
  • 数据库悲观锁:如SQL中的SELECT … FOR UPDATE,会锁定读取的记录,直到事务完成。

主要区别

特征乐观锁 (Optimistic Lock)悲观锁 (Pessimistic Lock)
假设假设没有冲突,允许多个线程并发操作。假设会发生冲突,确保只有一个线程能操作资源。
加锁时机操作前不加锁,操作完成后通过校验(如CAS、版本号)检测冲突。操作前立即加锁,锁住资源,其他线程无法访问该资源。
锁的粒度通过较小的锁粒度进行操作,通常只锁定必要的部分。锁的粒度较大,通常锁住整个资源,直到操作完成。
性能影响在无冲突的情况下,性能较好,因为不会被加锁机制阻塞。可能导致线程阻塞,性能较差,尤其在高并发时。
适用场景适用于冲突概率较低的场景,能够提高并发性能。适用于冲突概率较高的场景,能避免资源的不一致性。
冲突处理如果检测到冲突,需要重试或回滚操作。如果有其他线程持锁,当前线程需要等待锁的释放。
实现方式通过版本号、CAS等技术进行冲突检测和处理。通过锁机制(如ReentrantLock、synchronized)来控制访问。

优缺点

乐观锁

  • 优点:
    • 性能高: 在没有冲突的情况下,乐观锁几乎没有开销,因为它避免了线程的等待和锁的争用。
    • 低锁粒度: 乐观锁一般不占用锁资源,可以允许更高的并发。
    • 减少阻塞: 线程不会在资源上等待,而是进行自旋或重试,避免了线程的长时间阻塞。
  • 缺点:
    • 冲突处理复杂: 如果有冲突发生,乐观锁需要通过重试机制来处理,这可能会导致额外的开销和复杂性。
    • 不适用于高冲突场景: 当多个线程频繁争抢资源时,乐观锁的冲突可能会非常频繁,导致性能下降。

悲观锁

  • 优点:
    • 避免冲突: 因为操作前会加锁,保证了数据的一致性,避免了并发修改时出现错误。
    • 适用于高竞争场景: 在高竞争情况下,悲观锁可以保证线程安全,不需要进行重试。
  • 缺点:
    • 性能较差: 因为锁会阻塞其他线程,特别是在高并发环境下,容易导致性能瓶颈。
    • 可能引起死锁: 如果多个线程同时等待锁并且相互依赖,可能会导致死锁问题。
    • 锁粒度较大: 通常悲观锁需要较大粒度的锁,可能会锁住更多的资源,从而影响系统的吞吐量。

应用场景

乐观锁的适用场景:

  • 冲突较少的场景: 乐观锁适用于读多写少的场景,线程冲突的概率较低时,可以提高性能。例如,更新用户余额时,如果更新频率不高,可以使用乐观锁。
  • 高并发数据访问: 例如无锁队列、无锁堆栈等数据结构,能够在高并发下提供高效的操作。

悲观锁的适用场景:

  • 高竞争场景: 当多线程频繁争抢同一个资源时,悲观锁能确保线程操作的安全性。例如,数据库的事务处理,防止多个事务同时修改相同数据。
  • 需要确保数据一致性: 在数据一致性非常重要且冲突不可避免的情况下,悲观锁可以通过加锁确保不会发生数据不一致。
Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐