乐观锁与悲观锁

什么是悲观锁?

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

像 Java 中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。

互斥锁

互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞

对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。

所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。

自旋锁

自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。

一般加锁的过程,包含两个步骤:

  • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
  • 第二步,将锁设置为当前线程持有;

CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。

自旋锁是最简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。

自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对

它俩是锁的最基本处理方式,更高级的锁都会选择其中一个来实现,比如读写锁既可以选择互斥锁实现,也可以基于自旋锁实现。

读写锁

读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。

所以,读写锁适用于能明确区分读操作和写操作的场景

读写锁的工作原理是:

  • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
  • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。

所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。

知道了读写锁的工作原理后,我们可以发现,读写锁在读多写少的场景,能发挥出优势

另外,根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」。

读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取写锁。

而「写优先锁」是优先服务写线程,其工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取写锁。

读优先锁对于读线程并发性更好,但也不是没有问题。我们试想一下,如果一直有读线程获取读锁,那么写线程将永远获取不到写锁,这就造成了写线程「饥饿」的现象。

写优先锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被「饿死」。

既然不管优先读锁还是写锁,对方可能会出现饿死问题,那么我们就不偏袒任何一方,搞个「公平读写锁」。

公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。

互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。

什么是乐观锁?

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。

在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicIntegerLongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。

理论上来说:

  • 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
  • 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。

如何实现乐观锁?

乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。

版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

CAS 算法

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。

CAS 涉及到三个操作数:

  • V:要更新的变量值(Var)
  • E:预期值(Expected)
  • N:拟写入的新值(New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

优点:

  • 无锁并发:CAS 操作不使用锁,因此不会导致线程阻塞,提高了系统的并发性和性能。
  • 原子性:CAS 操作是原子的,保证了线程安全。

缺点:

  • ABA 问题:CAS 操作中,如果一个变量值从 A 变成 B,又变回 A,CAS 无法检测到这种变化,可能导致错误。
  • 自旋开销:CAS 操作通常通过自旋实现,可能导致 CPU 资源浪费,尤其在高并发情况下。
  • 单变量限制:CAS 操作仅适用于单个变量的更新,不适用于涉及多个变量的复杂操作。

乐观锁存在哪些问题?

ABA 问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

循环时间长、开销大

AS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:

  1. 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
  2. 可以避免在退出循环的时候因内存顺序冲而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。

只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

什么是 Java 的 Semaphore?

Semaphore 是信号量,广泛应用于各种操作系统中,相对于平日只允许一个线程访问临界区的 lock 和 synchronized 来说,信号量允许多线程同时访问一个临界区。

原理就简单的理解为初始化一个数,如果来了一个线程则把数减一,如果减一之后数的值小于 0 则阻塞当前线程,移入一个阻塞队列中,否则允许执行。

当一个线程执行完毕之后将数加一,并唤醒阻塞队列中的一个等待线程。

实际是内部有个继承自 AQS 的 Sync 类,通过依托 AQS 的封装来实现功能。

synchronized 关键字

synchronized 关键字主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令实现的,monitor 对象是同步的基本实现单元,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

不过两者的本质都是对对象监视器 monitor 的获取。

在 Java 1.6 之前,monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作,性能也很低。

但在 Java 1.6 的时候,Java 虚拟机对此进行了改进,提供了三种不同的 monitor 实现,也就是常说的三种不同的锁:

  • 偏向锁(Biased Locking)
  • 轻量级锁
  • 重量级锁

Java 的 synchronized 是怎么实现的?

Synchronized 的原理其实就是基于一个锁对象和锁对象相关联的一个 monitor 对象。

在偏向锁和轻量级锁的时候只需要利用 CAS 来操控锁对象头即可完成加解锁动作。

在升级为重量级锁之后还需要利用 monitor 对象,利用 CAS 和 mutex 来作为底层实现。

monitor 对象内部会有等待队列和条件等待队列,未竞争到锁的线程存储到等待队列中,获得锁的线程调用 wait 后便存放在条件等待队列中,解锁和 notify 都会唤醒相应队列中的等待线程来争抢锁。

然后由于阻塞和唤醒依赖于底层的操作系统实现,系统调用存在用户态与内核态之间的切换,所以有较高的开销,因此称之为重量级锁。

所以才会有偏向锁和轻量级锁的优化,并且引入自适应自旋机制,来提高锁的性能。

什么是 Java 中的锁自适应自旋?

这里指的就是 Syncronized 在身为重量级锁时候的自旋。

具体指的是在重量级锁时,一个线程如果竞争锁失败会进行自旋操作,说白了就是执行一些无意义的执行,空转 CPU 等着锁的释放。

因为一些情况下可能线程刚被阻塞,锁就被释放了,这样开销就比较大,所以自旋在一定程度上是有优化的。

形象一点就像怠速停车和熄火的区别,如果等待时候很长(长时候都拿不到锁),那肯定熄火划算(阻塞)。

如果一会儿就要出发(拿到锁),那怠速停车(自旋)比较划算。

不过因为这个自旋次数不好判断,所以引入自适应自旋。

说白了就是结合经验值来看,如果上次自旋一会儿就拿到锁,那这次多自旋几次,如果上次自旋很久都拿不到,这次就少自旋。

这就叫锁的自适应自旋。

说说 AQS 吧?

AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。AQS 就是一个抽象类,主要用来构建锁和同步器。

AQS 将一些操作封装起来,比如入队等基本方法,暴露出方法,便于其他相关 JUC 锁的使用。

比如 ReentrantLock、CountDownLatch、Semaphore 等等。

简单来说 AQS 就是起到了一个抽象、封装的作用,将一些排队、入队、加锁、中断等方法提供出来,具体加锁时机、入队时机等都需要实现类自己控制。

Semaphore 是什么?

synchronizedReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。

Semaphore 的使用简单,我们这里假设有 N(N>5) 个线程来获取 Semaphore 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。

当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。

Semaphore 有两种模式:。

  • 公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO;
  • 非公平模式: 抢占式的。

Java 中 ReentrantLock 的实现原理是什么?

ReentrantLock 其实就是基于 AQS 实现的一个可重入锁,支持公平和非公平两种方式。

内部实现依靠一个 state 变量和两个等待队列:同步队列和等待队列。

利用 CAS 修改 state 来争抢锁。

争抢不到则入同步队列等待,同步队列是一个双向链表。

条件 condition 不满足时候则入等待队列等待,是个单向链表。

是否是公平锁的区别在于:线程获取锁时是加入到同步队列尾部还是直接利用 CAS 争抢锁

Synchronized 和 ReentrantLock 有什么区别?

Synchronized 是 Java 内置的关键字,实现基本的同步机制,不支持超时,非公平,不可中断,不支持多条件。

ReentrantLock 是 JUC 类库提供的,由 JDK 1.5 引入,支持设置超时时间,可以避免死锁,比较灵活,并且支持公平锁,可中断,支持多条件判断。

ReentrantLock 需要手动解锁,而 Synchronized 不需要,它们都是可重入锁。

什么是 Java 的 CyclicBarrier?

从名字分析,这是一个可循环的屏障。

屏障的意思是:让一组线程都运行到同一个屏障点之后,线程会阻塞等待所有线程都达到这个屏障点,然后所有线程才得以继续执行。

来看一下用法:

它实际上是基于 ReentrantLock 和 Condition 的封装来实现这一功能的。

原理我先口述一下,因为面试官很有可能会问原理。

首先设置了达到屏障的线程数量,当线程调用 await 的时候计数器会减一,如果计数器减一不等于 0 的时候,线程会调用 condition.await 进行阻塞等待。

如果计数器减一的值等于0,说明最后一个线程也到达了屏障,于是如果有 barrierCommand 就执行 barrierCommand ,然后调用 condition.signalAll 唤醒之前等待的线程,并且重置计数器,然后开启下一代。

源码我就不贴了,建议自己看下,不难的,算上一大推注释都不到 500 行,核心方法就 60 几行。

什么是 Java 的 CountDownLatch?

这个锁其实和 CyclicBarrier 有点类似,都是等待一个节点的到达,但是还是不太一样的。

CyclicBarrier 是各个线程等待阻塞所有线程都达到一个节点之后,所有线程继续执行。

CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕,这个过程中其他线程是不会阻塞的。

CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。

CountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。

示例如下:

实现原理:内部有一个继承自 AQS 的 Sync 类,核心其实就是围绕一个整数 state。

初始化 state 的值,当调用一次 countDown 会把 state 的值减一,当 state 的值减到 0 的时候就会唤醒之前调用 await 等待的线程。

主要是依靠 AQS 封装的好,所以代码很少,原理也很清晰简单。

CountDownLatch 的两种典型用法

  1. 某一线程在开始运行前等待 n 个线程执行完毕 : 将 CountDownLatch 的计数器初始化为 n (new CountDownLatch(n)),每当一个任务线程执行完毕,就将计数器减 1 (countdownlatch.countDown()),当计数器的值变为 0 时,在 CountDownLatch 上 await() 的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
  2. 实现多个线程开始执行任务的最大并行性:注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 (new CountDownLatch(1)),多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。

什么是 Java 的 CompletableFuture?

实际项目中,一个接口可能需要同时获取多种不同的数据,然后再汇总返回,这种场景还是挺常见的。举个例子:用户请求获取订单信息,可能需要同时获取用户信息、商品详情、物流信息、商品推荐等数据。

如果是串行(按顺序依次执行每个任务)执行的话,接口的响应速度会非常慢。考虑到这些任务之间有大部分都是 无前后顺序关联 的,可以 并行执行 ,就比如说调用获取商品详情的时候,可以同时调用获取物流信息。通过并行执行多个任务的方式,接口的响应速度会得到大幅优化。对于存在前后调用顺序关系的任务,可以进行任务编排。

对于 Java 程序来说,Java 8 才被引入的 CompletableFuture 可以帮助我们来做多个任务的编排,功能非常强大。

Future 是什么?

Future 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。

这其实就是多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。

在 Java 中,Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:

  • 取消任务;
  • 判断任务是否被取消;
  • 判断任务是否已经执行完成;
  • 获取任务执行结果。

简单理解就是:我有一个任务,提交给了 Future 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future 那里直接取出任务执行结果。

CompletableFuture

Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。

Java 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。

CompletableFuture 的核心特性如下:

1)异步执行:通过 runAsync 和 supplyAsync 方法,可以异步地执行任务。

2)任务完成回调:使用 thenApply、thenAccept、thenRun 等方法,可以在任务完成后执行回调。

3)任务组合:可以将多个 CompletableFuture 组合在一起,通过 thenCombine、thenCompose 等方法,处理多个异步任务之间的依赖关系。

4)异常处理:提供了 exceptionally、handle 等方法,可以在异步任务发生异常时进行处理。

5)并行处理:可以通过 allOf 和 anyOf 方法,并行地执行多个异步任务,并在所有任务完成或任意一个任务完成时执行回调。

处理异步计算的结果

当我们获取到异步计算的结果之后,还可以对其进行进一步的处理,比较常用的方法有下面几个:

  • thenApply()
  • thenAccept()
  • thenRun()
  • whenComplete()

thenApply() 方法接受一个 Function 实例,用它来处理结果。

// 沿用上一个任务的线程池
public <U> CompletableFuture<U> thenApply(
Function<? super T,? extends U> fn) {
return uniApplyStage(null, fn);
}

//使用默认的 ForkJoinPool 线程池(不推荐)
public <U> CompletableFuture<U> thenApplyAsync(
Function<? super T,? extends U> fn) {
return uniApplyStage(defaultExecutor(), fn);
}
// 使用自定义线程池(推荐)
public <U> CompletableFuture<U> thenApplyAsync(
Function<? super T,? extends U> fn, Executor executor) {
return uniApplyStage(screenExecutor(executor), fn);
}

thenApply() 方法使用示例如下:

CompletableFuture<String> future = CompletableFuture.completedFuture("hello!")
.thenApply(s -> s + "world!");
assertEquals("hello!world!", future.get());
// 这次调用将被忽略。
future.thenApply(s -> s + "nice!");
assertEquals("hello!world!", future.get());

你还可以进行 流式调用

CompletableFuture<String> future = CompletableFuture.completedFuture("hello!")
.thenApply(s -> s + "world!").thenApply(s -> s + "nice!");
assertEquals("hello!world!nice!", future.get());

如果你不需要从回调函数中获取返回结果,可以使用 thenAccept() 或者 thenRun()。这两个方法的区别在于 thenRun() 不能访问异步计算的结果。

如何优化 Java 中的锁?

锁的优化主要有两种方式:

  1. 减少锁的持有时间
  2. 减少锁的粒度

其实第二点也是为了满足第一点,不过还是需要单独提下。

减少锁的持有时间其实很好理解,并发资源已操作完成后,里面释放锁,不然别的线程就会阻塞等待,这样就不高效了。

减少锁的粒度,比如以前用 Synchronized 修饰整个方法,可以优化下用代码块仅包括需要抢占的逻辑,减少整体锁定码逻辑。

又或者以前使用的是 HashTable,可以替换成 ConcurrentHashMap,是因为 Hashtable 虽然是线程安全的,但是它太粗暴了,它为所有的方法都上了同一把锁!

还有比如读读不需要互斥,写才需要互斥的场景,不要简单的用一把锁,可以用读写锁,这也是减少锁的粒度。

线程同步和线程协作有什么区别?

线程同步是指多个线程按照预定的先后次序进行运行,即在一个线程对内存进行操作时,其他线程都不能对该内存地址进行操作,直到该线程完成操作。线程同步的主要目的是避免多个线程同时访问共享资源时出现的数据不一致或竞态条件等问题。实现线程同步的方法包括使用同步关键字(如synchronized)、锁(如ReentrantLock)、信号量等。

线程协作是指多个线程之间通过某种机制来协调工作,以完成共同的任务。线程协作的主要目的是实现线程之间的通信和协作,例如一个线程等待另一个线程完成某个操作后再继续执行,或者多个线程按照一定的顺序执行等。实现线程协作的方法包括使用等待/通知机制(如wait()/notify()/notifyAll())、条件变量、线程阻塞队列等。

总的来说,线程同步主要关注的是线程之间的互斥和同步,以保证共享资源的正确性;而线程协作主要关注的是线程之间的通信和协作,以实现更复杂的任务。在实际编程中,线程同步和线程协作通常是结合使用的,以实现高效、正确的多线程程序。

参考链接

Java并发常见面试题总结(上) | JavaGuide

Java并发常见面试题总结(中) | JavaGuide

Java 与 JVM — 八股文 (interview-points.readthedocs.io)

AQS 详解 | JavaGuide

CompletableFuture 详解 | JavaGuide