当前位置: 首页 > news >正文

2.多线程进阶

1.锁策略

1.1乐观锁vs悲观锁

悲观锁: 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

乐观锁: 假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做


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


通俗理解:

同学 A 和 同学 B 想请教老师一个问题.

同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: "老师

你忙嘛? 我下午两点能来找你问个问题嘛?" (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.

同学 B 认为 “老师是比较闲的, 我来问问题, 老师大概率是有空解答的”. 因此同学 B 直接就来找老师.(没加锁, 直接访问资源),如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁

1.2读写锁

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需

要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。

读写锁:在执行加锁操作时需要额外表明读写意图,读者之间并不互斥,而写者则要求与任何人互斥。


一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.

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

两个线程都要写一个数据, 有线程安全问题.

一个线程读另外一个线程写, 也有线程安全问题


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

锁.

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

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

读写锁特别适合于 “频繁读, 不频繁写” 的场景中.

1.3重量级锁vs轻量级锁

CPU 提供了 “原子操作指令”;操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁;

JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类。

在这里插入图片描述

重量级锁****: 加锁机制重度依赖了 OS 提供了 mutex

大量的内核态用户态切换,很容易引发线程的调度,这两个操作成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 “沧海桑田”.

轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex.,少量的内核态用户态切换不太容易引发线程调度.

synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.

1.4自旋锁

如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁。


自旋锁是一种典型的轻量级锁的实现方式.

优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.

缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).

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

1.5公平锁vs不公平锁

公平锁: 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.

非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁

synchronized 是非公平

1.6可重入锁vs不可重入锁

可重入锁的字面意思是“可以重新进入的锁“,即允许同一个线程多次获取同一把锁。

比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入

(因为这个原因可重入锁也叫做递归锁

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

synchronized关键字锁都是可重入的而 Linux 系统提供的 mutex 是不可重入锁


理解 “把自己锁死”

一个线程没有释放锁, 然后又尝试再次加锁.

按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第

二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无

法进行解锁操作. 这时候就会 死锁. 这样的锁称为不可重入锁

synchronized 是可重入锁

2.CAS(compare and swap)

2.1定义

一个 CAS 涉及到以下操作:

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

1. 比较 A 与 V 是否相等。(比较)

2. 如果比较相等,将 B 写入 V。(交换)

3. 返回操作是否成功。


当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线

程只会收到操作失败的信号。 CAS 可以视为是一种乐观锁.

2.2实现

针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:

java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;

unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;

Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子

性。

简而言之,是因为硬件予以了支持,软件层面才能做到

2.3应用

实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.

典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

假设两个线程同时调用 getAndIncrement

(1) 两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)

(2)线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值.

(3)线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环,在循环里重新读取 value 的值赋给 oldValue

(4)线程2接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作


实现自旋锁

public class SpinLock {private Thread owner = null;public void lock(){// 通过 CAS 看当前锁是否被某个线程持有. // 如果这个锁已经被别的线程持有, 那么就自旋等待. // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. while(!CAS(this.owner, null, Thread.currentThread())){}}public void unlock (){this.owner = null;}
}

2.4CAS的ABA问题

(1)ABA问题

假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.

接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要先读取 num 的值, 记录到 oldNum 变量中.

使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z;

(2)产生bug

但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A,到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.

大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一 些特殊情况

假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50

操作. 我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.

正常的过程

1) 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期

望更新为 50.

2) 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.

3) 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.

异常的过程

1) 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期

望更新为 50.

2) 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.

3) 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100 !!

4) 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作

(3)解决bug

给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.

CAS 操作在读取旧值的同时, 也要读取版本号.

真正修改的时候:如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1;如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了)

3.Synchronized原理

3.1特点

  • 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  • 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  • 实现轻量级锁的时候大概率用到的自旋锁策略
  • 是一种不公平锁
  • 是一种可重入锁
  • 不是读写锁

3.2加锁过程

							![](https://i-blog.csdnimg.cn/img_convert/4fddeb13c678a8dca212c8c2939f034f.png)

(1)偏向锁

第一个尝试加锁的线程, 优先进入偏向锁状态.

偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程. 如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)

如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.

偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.

但是该做的标记还是得做的, 否则无法区分何时需要真正加锁

(2)自旋锁

随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).

此处的轻量级锁就是通过 CAS 来实现. 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)

如果更新成功, 则认为加锁成功,如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).

自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源,因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.

(3)重量级锁

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁(内核提供的 mutex) .

执行加锁操作, 先进入内核态,在内核态判定当前锁是否已经被占用。如果该锁没有占用, 则加锁成功, 并切换回用户态;如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.

3.3优化操作

(1)锁消除

编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.

“锁消除” :有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)

(2)锁粗化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.

实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁. 但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁

4.Collable接口

Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果

      Callable<Integer> callable=new Callable<Integer>() {@Overridepublic Integer call() throws Exception {int sum=0;for (int i = 0; i < 100; i++) {sum+=i;}return sum;}};FutureTask<Integer> futureTask=new FutureTask<>(callable);Thread t=new Thread(futureTask);t.start();int result=futureTask.get();System.out.println(result);
  • 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
  • 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
  • 把 callable 实例使用 FutureTask 包装一下.
  • 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的 call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.

在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果.


  • Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务, Runnable 描述的是不带返回值的任务.
  • Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定,FutureTask 就可以负责这个等待结果出来的工作

5.JUC(java.util.concurrent)的常见类

5.1**ReentrantLock**

用来实现互斥效果, 保证线程安全.

ReentrantLock 的用法:

  • lock(): 加锁, 如果获取不到锁就死等.
  • trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
  • unlock(): 解锁
ReentrantLock lock = new ReentrantLock(); lock.lock();   
try {    // working    
} finally {    lock.unlock()    
}  

ReentrantLock VS Synchronized

  • synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准 库的一个类, 在 JVM 外实现的(基于 Java 实现).
  • synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.
  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启 公平锁模式.
  • 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一 个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指 定的线程

5.2原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个 :

AtomicBoolean,AtomicInteger ,AtomicIntegerArray ,AtomicLong ,AtomicReference AtomicStampedReference

6.线程池

如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 "池子"中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创

ExecutorService 和 Executors

ExecutorService 表示一个线程池实例.

Executors 是一个工厂类, 能够创建出几种不同风格的线程池.

ExecutorService 的 submit 方法能够向线程池中提交若干个任务

  ExecutorService pool=Executors.newFixedThreadPool(10);pool.submit(new Runnable() {@Overridepublic void run() {System.out.println("hi");}});

7.信号量Semaphore

信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.

        Semaphore s = new Semaphore(4);Runnable runnable = new Runnable() {@Overridepublic void run() {try {System.out.println("获取资源");s.acquire();System.out.println("获取到了");Thread.sleep(1000);System.out.println("我释放资源了");s.release();} catch (InterruptedException e) {e.printStackTrace();}}};for (int i = 0; i < 10; i++) {Thread t=new Thread(runnable);t.start();}

acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)

8.CountDownLatch(同时等待 N 个任务执行结束)

  • 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
  • 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
  • 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了
  CountDownLatch latch=new CountDownLatch(10);Runnable r=new Runnable() {@Overridepublic void run() {try {Thread.sleep((long)Math.random()*10000);latch.countDown();} catch (InterruptedException e) {e.printStackTrace();}}};for (int i = 0; i < 10; i++) {new Thread(r).start();}latch.await();System.out.println("比赛结束");

9.线程安全的集合类

9.1**多线程环境使用 ArrayList**

  • 自己使用同步机制 (synchronized 或者 ReentrantLock)
  • Collections.synchronizedList(new ArrayList);

synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.

  • 使用 CopyOnWriteArrayList

CopyOnWrite容器即写时复制的容器。

当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy, 复制出 一个新的容器,然后新的容器里添加元素, 添加完元素之后,再将原容器的引用指向新的容器。

CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器


优点****:

在读多写少的场景下, 性能很高, 不需要加锁竞争.

缺点****:

1. 占用内存较多.

2. 新写的数据不能被第一时间读取到.

9.2多线程环境使用队列

  • ArrayBlockingQueue 基于数组实现的阻塞队列
  • LinkedBlockingQueue 基于链表实现的阻塞队列
  • PriorityBlockingQueue 基于堆实现的带优先级的阻塞队列
  • TransferQueue最多只包含一个元素的阻塞队列

9.3多线程环境使用哈希表

(1)HashTable

只是简单的把关键方法加上了 synchronized 关键字,这相当于直接针对 Hashtable 对象本身加锁.

如果多线程访问同一个 Hashtable 就会直接造成锁冲突. size 属性也是通过 synchronized 来控制同步, 也是比较慢的. 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低

(2)ConcurrentHashMap

读操作没有加锁(但是使用了volatile保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然 是用 synchronized, 但是不是锁整个对象, 而是 “锁桶” (用每个链表的头结点作为锁对象), 大大降低了锁冲突概率.

  • 充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况.
  • 优化了扩容方式: 化整为零,发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.

扩容期间, 新老数组同时存在. 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小 部分元素. 搬完最后一个元素再把老数组删掉. 这个期间, 插入只往新数组加.

这个期间, 查找需要同时查新数组和老数组

10.死锁

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

10.1避免死锁

死锁产生的四个必要条件:

  • 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
  • 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
  • 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
  • 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。

破坏任意一个,就可以避免死锁产生。

http://www.dtcms.com/a/597493.html

相关文章:

  • 建湖网站定制重庆网站建设齐重庆零臻科技
  • 网站策划的内容wordpress3.8 中文标签
  • Rust评测案例:Rust、Java、Python、Go、C++ 实现五大排序算法的执行时间效率比较(基于 OnlineGDB 平台)
  • golang redis 管道
  • go-dongle v1.2.0 发布,新增 SM2 非对称椭圆曲线加密算法支持
  • ⚡️2025-11-11GitHub日榜Top5|Go AI代理开发框架
  • 网站建设g如何做设计网站页面
  • 智能推荐助力数据驱动决策模式提升效率
  • 博客系统 wordpressseo公司怎么样
  • 网站建设与管理 期末软件库资源共享
  • NetSonar网络诊断工具的安装与基本使用
  • 各国网站域名桂林市区有什么好玩的地方景点
  • 陕西省两学一做网站深圳今天新闻头条
  • Git Commend HandBook
  • MFC:微软基础类库的全面解析
  • 郑州网站建设技术托管营销推广方法有哪些
  • Python工具将本地Embedding转换成onnx格式
  • 手机类网站设计赣州新闻综合频道回放
  • 嘉兴网站免费制作判断网站开发语言
  • JSF是什么
  • vscode 设置中文-语言
  • flutter vscode 终端无法使用fvm 版本切换、项目运行
  • 数据结构:计算机高效处理数据的核心基石
  • 网站怎么做能赚钱吗溧水做网站
  • 自己建个电影网站可以吗南海网站建设
  • 使用 C# 提取 Word 表格数据
  • 3DEXPERIENCE DELMIA Role: LTR - Lean Team Player
  • 手机网站seo教程下载wordpress获取文章图片地址
  • 网站海外推广谷歌seo方案开一个网站建设公司需要什么软件
  • 基于 Vue3 封装大华 RTSP 回放视频组件(PlayerControl.js 实现)