JAVAEE->多线程:锁策略

锁的分类

1.乐观锁和悲观锁

**乐观锁:**预测接下来锁冲突的概率不大,就少做一些工作。

**悲观锁:**预测接下来锁冲突的概率很大,就多做一些工作。

2.重量级锁和轻量级锁

**重量级锁:**锁的开销比较大(和悲观锁有关联,但乐观锁时预测,并非实际开销)

**轻量级锁:**锁的开销比较小(和乐观锁有关联)

3.自旋锁和挂起等待锁

**自旋锁:**轻量级锁的实现

当一个线程尝试获取自旋锁,但发现锁已经被其他线程持有(即锁被"占用了")时,它不会立即进入休眠或阻塞状态。相反,它会持续地循环(while)检查锁是否被释放。这个循环检查的过程就是"自旋"。线程会不断地占用 CPU,直到锁被释放为止。

使用自旋锁的前提是预期锁冲突概率不大,只要其他线程释放了锁,它就能第一时间加锁成功;

但如果有很多线程要加锁,就不要使用自旋锁,因为会浪费cpu资源

**挂起等待锁:**重量级锁的实现

当一个线程尝试获取等待挂起锁,但发现锁已经被其他线程持有时,它会立即被挂起(阻塞)。被挂起的线程会放弃 CPU,进入"等待"或"休眠"状态,不再消耗 CPU 资源。只有当持有锁的线程释放锁后,操作系统或调度器才会将等待队列中的一个线程唤醒,使其重新竞争锁。

阻塞状态和运行状态的切换需要开销

4.可重入锁和不可重入锁

**可重入锁:**如synchronized,加锁一段代码,锁里面可以再进行一次加锁,锁里面可以嵌套多个锁,里面是用计数器这种方式对加锁的数量进行计数,并判断是否解锁,是可重入锁。

**不可重入锁:**系统自带的锁,不能连续加锁两次。

5.普通互斥锁和读写锁

普通互斥锁:synchronized 关键字或 ReentrantLock 这样的传统互斥锁(也称为排他锁),在任何时刻都只允许一个线程访问被保护的共享资源。

**读写锁:**这里加锁的情况分为两种:给读加锁,给写加锁

读锁(共享锁) :当一个线程持有读锁时,其他线程也可以同时获取读锁。这意味着多个读线程可以同时并发地访问共享资源,只要没有线程持有写锁。读锁之间是共享的,互不阻塞。

写锁(排他锁) :当一个线程持有写锁时,任何其他线程(无论是读线程还是写线程)都不能获取读锁或写锁。写锁是排他的,它会阻塞所有其他读写操作,确保在数据修改时数据的一致性。

为啥要引入读写锁?

  • 提高读并发性:允许多个读线程同时访问共享资源,显著提高了读操作的吞吐量和性能。
  • 保证数据一致性:在写操作发生时,通过写锁保证了数据的独占访问,防止了数据在修改过程中被其他线程读取或修改,维护了数据的一致性和完整性。
  • 优化性能 :在读操作远多于写操作的场景下,读写锁能够显著优于传统的互斥锁,因为它减少了不必要的线程阻塞和上下文切换。

6.公平锁和非公平锁

此处的公平定义为:先来后到原则

场景:很多线程去尝试加一把锁时,一个线程拿到锁,其它线程等待;

当这把锁被释放时,哪个线程能够拿到锁?

**公平锁:**先来后到顺序

**非公平锁:**按照"均等"概率(随即调度),竞争锁

系统加锁api默认为非公平,需要引入队列,来实现公平锁。

java中的synchronized属于哪种锁?

:synchronized具有自适应

当前锁冲突的激烈程度不大,它就处于乐观锁、轻量级锁、自旋锁;

当前锁冲突的激烈程度很大,它就处于悲观锁、重量级锁、等待挂起锁

synchronized不是读写锁,是可重入锁,是非公平锁

synchronized几个重要机制

1.锁升级

  • 偏向锁 (Bias Lock)

    • 思想:懒汉模式,能不加锁就不加锁。
    • 特点:当只有一个线程反复访问同步代码时,它就"偏向"这个线程。锁只是在对象头做个标记,几乎没有开销。该线程下次再来,直接通过标记就能进入,无需同步操作。
    • 作用 :在无竞争情况下,大幅提高效率。
    • 升级 :一旦有第二个线程来竞争,偏向锁就会被撤销,升级为轻量级锁。
  • 轻量级锁 (Lightweight Lock)

    • 思想 :通过自旋(线程忙等循环尝试获取锁)实现。
    • 特点 :适用于少量竞争(线程交替进入)的场景。当锁被释放时,自旋的线程能快速获取锁。
    • 优点:避免了线程阻塞和唤醒的开销(上下文切换)。
    • 缺点:如果竞争激烈或持有锁的时间长,自旋会白白消耗大量 CPU 资源。
    • 升级:当竞争变得非常激烈,自旋无法快速获取锁时,会升级为重量级锁。
  • 重量级锁 (Heavyweight Lock)

    • 思想 :通过挂起等待实现。
    • 特点 :适用于大量竞争的场景。获取不到锁的线程会被阻塞(挂起),放弃 CPU 资源,进入等待队列。等锁被释放后,再被唤醒去竞争。
    • 优点:线程阻塞让出 CPU,节省了 CPU 资源,可以被其他任务利用。
    • 缺点:涉及线程上下文切换,开销最大。
    • 注意 :在当前 JVM 版本中,synchronized 锁一旦升级到重量级锁,通常不会再降级

2.锁消除

锁消除是 JVM 的一种智能优化。

工作原理 :如果 JVM 发现某段 synchronized 代码在任何情况下都不可能发生多线程竞争 (比如同步的是一个局部变量),它就会直接把这个锁完全移除,节省开销。

与偏向锁的区别:

锁消除 :JVM 提前判断 (编译时),觉得没必要加锁,直接删除

偏向锁 :JVM 运行时观察 ,发现当前只有一个线程在用,就给它一个"专属标记",下次这个线程来了就直接进。有竞争时才会升级。

3.锁粗化

锁粗化是 JVM 的一种性能优化。

频繁的加锁解锁(涉及到锁竞争),肯定会消耗更多的硬件资源,但是如果能把一段代码的多个加锁、解锁操作,优化成只有一次加锁、解锁,这样也能提高效率。

CAS(Compare-And-Swap)

CAS 是一种 无锁的原子操作 ,全称是 比较并交换,它的作用是:

  • 比较内存中的某个值和期望值是否相等,

  • 如果相等,就将内存中的值更新为新值,

  • 如果不相等,说明被别的线程改过,就不做修改。

这个过程是原子性的,不会被线程调度打断。


CAS 的三个操作数

  • 内存地址(V):需要更新的变量的地址

  • 期望值(A):当前线程期望内存中变量的值

  • 新值(B):希望更新成的新值


CAS 的工作过程

  1. 线程读取内存地址 V 中的值

  2. 比较该值是否等于期望值 A

  3. 如果相等,将 V 更新为新值 B,操作成功

  4. 如果不相等,说明其他线程已修改,操作失败,通常会重试


CAS 优点

  • 无锁,避免了加锁带来的上下文切换和阻塞,提高性能

  • 实现原子操作,保证线程安全


CAS 缺点

  • ABA 问题:值可能从 A 变成 B 又变回 A,导致 CAS 误判成功

  • 循环时间长:如果竞争激烈,CAS 重试次数多,性能下降

  • 只能保证一个变量的原子操作,不适合复杂操作


CAS 在 Java 中的应用

Java 的 java.util.concurrent.atomic 包中大量用到了 CAS,比如 AtomicIntegerincrementAndGet(),底层就是 CAS 实现。

什么是 ABA 问题?

ABA问题描述的是在使用CAS操作时,比较的值从A变成了B,又变回了A,这种情况下CAS操作会误以为数据没有被修改过,从而导致错误。


举个简单的例子

假设某个变量初始值是A:

  1. 线程1执行CAS时,期望值是A。

  2. 在这期间,线程2把变量从A改成了B,然后又改回了A。

  3. 线程1执行CAS比较时,发现变量还是A(和期望值相同),CAS成功。

  4. 但实际上变量经历了变化,线程1却没有察觉。

这在某些场景下会带来严重错误,尤其是在基于CAS实现的锁或者内存管理时。


为什么ABA会导致问题?

CAS只关心值是不是和期望值相等,不关心中间是否有变化。

如果值变成别的东西再变回来了,CAS操作会误以为值一直没变过。


如何解决ABA问题?

1. 使用版本号(带标记的引用)

  • 将值和版本号绑定成一个整体(如结构体或对象),每次修改时,版本号增加。

  • CAS比较时不仅比较值,还比较版本号,避免ABA问题。

Java中的AtomicStampedReference就是基于这种思路。

2. 使用带有时间戳的对象引用

  • 每次更新值时附带时间戳或版本号,保证唯一性。

Java中的解决方案

  • AtomicStampedReference<V>:提供了带"版本号"的原子引用。

  • AtomicMarkableReference<V>:提供了带"标记位"的原子引用,能解决类似问题。

JUC并发包

1.Callable接口

创建线程的一种方式,要返回结果。(Runnable不返回结果)

因为Thread没有关于Callable的构造函数,所有用FutureTask。

由此可以复习一下线程的创建方式:

1.继承Thread,重写run(创建单独类,也可匿名内部类)

2.实现Runnable,重写run(创建单独类,也可匿名内部类)

3.实现Callable,重写run(创建单独类,也可匿名内部类)

4.使用lambda表达式

5.ThreadFactory工厂

6.线程池

2.ReentrantLock

特性:

1.在加锁的时候有两种方式:lock和tryLock,更多操作空间

2.提供了公平锁的实现(默认非公平锁)

3.提供更强大的等待通知机制(搭配Condition类,实现等待通知)。

4.可重入

synchronized和ReentrantLock之间的区别:

1.synchronized自动加锁解锁,ReentrantLock需要手动加锁解锁(容易忘记解锁)。

2.synchronized是非公平锁,ReentrantLock默认非公平,但可实现公平锁。

3.synchronized不能响应中断,ReentrantLock可以响应中断,解决死锁问题。

4.synchronized时JVM层面通过监视器实现的;ReentrantLock基于AQS实现。

ReentrantLock 可以结合 Condition 实现类似 wait/notify 的线程通信,但更灵活

3.Semaphore(信号量)

Semaphore 是 JUC(java.util.concurrent)包中提供的一个并发工具类,它通过控制许可数量,来限制同时访问某个资源的线程数量。

线程在访问资源前必须先获取许可证,使用完毕后释放。

可以将它比作一个"车位计数器",比如有 3 个车位,最多同时容纳 3 辆车,多的就得排队等位。

Semaphore 的原理

Semaphore 内部基于 AQS(AbstractQueuedSynchronizer) 实现,其核心是:

  • 每次 acquire() 会尝试将许可数减 1;

  • 若许可数不足,则线程进入等待队列;

  • 每次 release() 会将许可数加 1,并唤醒等待线程

4.CountDownLatch

CountDownLatch 是 JUC 包中提供的一个 同步辅助工具类 ,用于控制一个或多个线程 等待其他线程完成操作

CountDownLatch的原理

  • CountDownLatch 内部使用 AQS(AbstractQueuedSynchronizer) 实现;

  • 初始时维护一个状态值(count);

  • countDown() 将状态减一;

  • await() 阻塞,直到状态为 0。

注意:一次性使用,count=0之后不可重置

应用:

多个任务并发执行,主线程等待结果;主线程等待多个子线程加载完毕

线程安全的集合类

一、早期线程安全集合(不推荐)

  • Vector、Stack、Hashtable 等类是 Java 早期引入的集合类,它们通过 synchronized 保证线程安全。

  • 它们的方法都是加锁的,例如:get()put() 都是同步方法。

  • 缺点

    • 性能差:粗粒度锁,全部方法加锁,效率低。

    • 不灵活:很少适应现代并发编程的高性能要求。

  • 结论现在已经不推荐使用,可能在未来被移除。

二、对非线程安全集合加锁(较推荐)

  • 示例

    Collections.synchronizedList(new ArrayList<>());

  • 本质是对普通集合加一层同步包装,返回一个线程安全的集合。

  • 特点:

    • 底层依然是 ArrayList 等。

    • 多线程访问时线程安全,但 遍历时仍需手动加锁

    • 类似"临时解决方案",适合简单场景。

三、现代推荐:并发容器类

1. CopyOnWriteArrayList(适合读多写少)

  • 写时复制思想(Copy on Write):

    • 修改时会复制一份副本,在副本上修改。

    • 修改完成后替换原数据(往往是一个引用)。

  • 优点:

    • 读写分离:读操作不加锁,效率高。

    • 适用于读多写少的场景(如系统配置的更新,配置文件不大,一个线程修改之后,重新加载)。

  • 缺点:

    • 当前操作的ArrayList不能太大,不然拷贝成本高,修改代价大(需要复制数组)。

    • 不适合频繁修改的情况,适合一个线程去修改,而不是多个线程同时修改。

2.HashTable和ConcurrentHashMap

1.HashTable

只是简单把关键方法 加上synchronized关键字

只要两个线程操作同一个Hashtable会出现锁竞争

2.ConcurrentHashMap

ConcurrentHashMap 是 Java 并发集合框架 (java.util.concurrent) 中的一个高性能线程安全的 HashMap 实现,适用于高并发场景。

它通过分段锁(JDK 7)和 CAS + synchronized(JDK 8) 实现线程安全

JDK7:分段锁(Segment)

将数据分成多个Segment(默认16个),每个Segment都是一个独立的HashEntry数组。

写操作只锁对应的Segment,不影响其它Segement的读写。

缺点:并发度受Segment数量限制。

JDK8:CAS+synchronized

改用Node数组+链表/红黑树(类似HashMap)

读操作:完全无锁(volatile保证内存可见性)

写操作:

使用CAS(Compare-And-Swap)尝试无锁修改(比如使用变量记录hash表中的元素个数);

如果CAS失败,则用synchronized锁住单个头节点Node;

扩容:支持多线程协同扩容(helpTransfer)

ConcurrentHashMap扩容机制详解:

1.触发扩容的条件:

a.元素数量超过阈值:

当元素数量超过capacity*loadFactor时,触发扩容(默认负载因子为0.75)

b.链表过长

当链表长度达到8且数组长度小于64时,优先扩容而非转为红黑树

c.协助扩容

当一个线程发现其它线程正在扩容时,会协助进行扩容(Help Transfer机制)

对扩容进行优化:

Hashtable和HashMap扩容时需要把所有元素拷贝一遍,耗时;

ConcurrentHashMap做出了优化,需要扩容时,并非一次完成,每次搬运一部分数据。

面试题:

ConcurrentHashMap 不允许 null 是为了避免在多线程下歧义,比如返回 null 是表示键不存在,还是值就是 null?

ConcurrentHashMap扩容时如何保证线程安全?

ConcurrentHashMap为啥不允许为null?

请你讲讲 ConcurrentHashMap 的读操作和写操作是如何协同工作以实现高并发的?

ConcurrentHashMap 和 CopyOnWriteArrayList 比较

相关推荐
liuyang-neu2 分钟前
java内存模型JMM
java·开发语言
UFIT23 分钟前
NoSQL之redis哨兵
java·前端·算法
刘 大 望26 分钟前
数据库-联合查询(内连接外连接),子查询,合并查询
java·数据库·sql·mysql
怀旧,32 分钟前
【数据结构】6. 时间与空间复杂度
java·数据结构·算法
大春儿的试验田1 小时前
Parameter ‘XXX‘ not found. Available parameters are [list, param1]
java
我很好我还能学2 小时前
【面试篇 9】c++生成可执行文件的四个步骤、悬挂指针、define和const区别、c++定义和声明、将引用作为返回值的好处、类的四个缺省函数
开发语言·c++
程序员JerrySUN2 小时前
[特殊字符] 深入理解 Linux 内核进程管理:架构、核心函数与调度机制
java·linux·架构
2302_809798322 小时前
【JavaWeb】Docker项目部署
java·运维·后端·青少年编程·docker·容器
蓝婷儿2 小时前
6个月Python学习计划 Day 16 - 面向对象编程(OOP)基础
开发语言·python·学习
渣渣盟2 小时前
基于Scala实现Flink的三种基本时间窗口操作
开发语言·flink·scala