一:常见的锁策略
1.1 乐观锁 vs 悲观锁
乐观锁和悲观锁是并发控制的两种不同策略,用于处理多个线程同时访问共享资源的情况。它们的主要区别在于对并发冲突的处理方式。
悲观锁是一种较保守的并发控制策略,它假设在整个事务过程中会发生冲突,因此在访问共享资源之前会先加锁。通过锁定资源,其他线程需要等待锁被释放才能继续访问。悲观锁常用于对共享资源进行长时间占用的场景,如数据库中的表锁和行锁。悲观锁可能会导致性能下降,特别是在高并发情况下,因为它会阻塞其他线程的操作。
乐观锁是一种较乐观的并发控制策略,它假设在整个事务过程中不会发生冲突,因此不会加锁。而是通过在更新共享资源时检查是否有其他线程同时修改该资源。如果没有冲突,则更新成功;如果冲突,则返回用户错误的信息,让用户决定如何去做,需要进行回滚或重新尝试。乐观锁常用于对共享资源进行短时间占用的场景,如线程间的读写操作冲突。乐观锁可以避免锁的开销,提高性能,但在并发冲突较频繁的情况下可能需要频繁的回滚和重试。
1.1.1Synchronized和乐观锁悲观锁
注意:Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
在初始使用synchronized时,它采用了乐观锁策略。乐观锁的意思是,线程在访问临界区之前,假设没有竞争,并且直接进入临界区执行操作。如果没有发生冲突,那么进程可以快速完成任务。但是,如果发生冲突,即其他线即程正在访问临界区并且获取了锁,当前线程的操作将失败。
当synchronized发现锁竞争很频繁时,就会自动切换到悲观锁策略。即线程在访问临界区之前,假设会发生竞争,并且会先申请锁。如果锁没有被其他线程占用,该线程可以顺利进入临界区执行操作。如果锁已被其他线程占用,当前线程将会被阻塞,直到锁被释放。
通过自动切换策略,synchronized可以根据实际情况调整使用的锁策略,从而在竞争较少时提供较高的并发性能,而在竞争激烈时保证线程安全。
1.1.2乐观锁的一个应用
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 “版本号” 来解决.
假设我们需要多线程修改 “用户账户余额”.设当前余额为 100. 引入一个版本号 version, 初始值为 1. 并且我们规定 "提交版本必须大于记录当前版本才能执行更新余额
- 线程 A 此时准备将其读出( version=1, balance=100 ),线程 B 也读入此信息( version=1,balance=100 ).
2) 线程 A 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20( 100-20 );
3) 线程 A 完成修改工作,将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=50),写回到内存中;
4) 线程 B 完成了操作,也将版本号加1( version=2 )试图向内存中提交数据( balance=80),但此时比对版本发现,操作员 B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败.
1.2 读锁 vs 写锁(读写锁)
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
- 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
- 两个线程都要写一个数据, 有线程安全问题.
- 一个线程读另外一个线程写, 也有线程安全问题.
读写锁就是把读操作和写操作区分对待.
读写锁允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。读写锁可以提高并发性能,使得多个线程可以同时读取数据,而在写操作时保持独占性,保证数据的一致性和完整性。
读写锁由两个部分组成:读锁和写锁。在读锁下,多个线程可以同时获取读锁,读取共享资源没有互斥的限制。而在写锁下,只有一个线程可以获取写锁,其他线程无法获取读锁或写锁,保证了写操作的原子性和独占性。
读写锁的特点如下:
- 多个线程可以同时获取读锁,实现读并发性。
- 写锁是独占的,只有一个线程可以获取写锁,实现写的原子性和独占性。
- 读锁和写锁之间是互斥的,即当有线程获取写锁时,其他线程无法获取读锁或写锁。
- 读锁可以降级为写锁,即在获取读锁的情况下再获取写锁,而写锁无法升级为读锁。
读写锁适用于读多写少的场景,可以有效地提高系统的并发性能。对于读操作比写操作频繁的情况,使用读写锁可以减少线程争抢和等待的时间,提高系统的响应速度。
Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写
锁.
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock方法进行加锁解锁.
- ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock方法进行加锁解锁.
其中,
- 读加锁和读加锁之间, 不互斥.
- 写加锁和写加锁之间, 互斥.
- 读加锁和写加锁之间, 互斥.
注意:Synchronized 不是读写锁.
1.3 重量级锁 vs 轻量级锁
首先我们要知道:锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的.
- CPU 提供了 “原子操作指令”.
- 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
- JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类.
注意, synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作
重量级锁和轻量级锁是Java中用于实现同步的两种不同机制。它们的主要区别在于锁的获取和释放的开销。
-
重量级锁(Heavyweight Lock):
- 重量级锁是使用操作系统的互斥量(Mutex)来实现的,在Java中通常是通过synchronized关键字来使用。
- 重量级锁的获取和释放需要涉及用户态和内核态之间的切换,这种切换会消耗较多的时间。
- 重量级锁适用于多个线程访问一个共享资源且访问时间较长的情况。
-
轻量级锁(Lightweight Lock):
- 轻量级锁是一种乐观锁策略,在Java中是通过对象头中的标志位来实现的。
- 当只有一个线程访问一个对象时,它会尝试用CAS操作将对象头中的标志位设置为轻量级锁。
- 如果CAS操作成功,那么线程就获得了轻量级锁,可以直接进入临界区进行操作,不需要进入内核态。
- 如果有多个线程进行竞争,那么轻量级锁会膨胀为重量级锁,此时涉及到内核态的互斥操作。
- 轻量级锁适用于多个线程访问一个共享资源且访问时间较短的情况。
如何理解用户态 vs 内核态: 想象去银行办业务. 在窗口外, 自己做, 这是用户态. 用户态的时间成本是比较可控的. 在窗口内,
工作人员做, 这是内核态. 内核态的时间成本是不太可控的. 如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的.
总结:重量级锁和轻量级锁是Java中用于实现同步的两种不同机制,主要区别在于锁的获取和释放的开销。重量级锁使用操作系统的互斥量实现,获取和释放需要涉及用户态和内核态之间的切换,适用于多个线程访问一个共享资源且访问时间较长的情况。轻量级锁采用乐观锁策略,使用CAS操作来尝试获取锁,如果竞争激烈则会膨胀为重量级锁,适用于多个线程访问一个共享资源且访问时间较短的情况。
注意:synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
1.4 自旋锁 vs 挂起等待锁
自旋锁和挂起等待锁是多线程编程中常用的两种锁策略,用于解决线程之间的竞争条件。
- 自旋锁(Spin Lock):
自旋锁是一种忙等待锁策略,它在获取锁时,使用循环来反复检查锁的状态,直到锁被释放。如果锁的状态为被占用,则当前线程会一直处于循环等待的状态,直到其他线程释放了锁。
自旋锁适用于锁竞争激烈但等待锁时间较短的情况。好处是线程不会进入阻塞状态,避免了线程切换的开销,但同时也会占用CPU资源。
- 挂起等待锁(Suspension Lock):
挂起等待锁是一种在获取锁失败时,将线程置为休眠状态等待锁释放的策略。当一个线程尝试获取锁时,如果锁已被其他线程占用,当前线程会被挂起,不会再占用CPU资源,直到锁被释放并唤醒线程。
挂起等待锁适用于锁竞争不激烈或等待锁时间较长的情况。它可以有效地减少CPU资源的使用,但也引入了线程切换和上下文切换的开销。
理解自旋锁和挂起等待锁 :
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了
- 挂起等待锁:陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意,这个很长的时间间隔里,女神可能已经换了好几个男票了).
- 自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.
自旋锁是一种典型的 轻量级锁 的实现方式.
- 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
- 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
1.5公平锁 vs 非公平锁
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.当线程 A 释放锁的时候, 会发生啥呢?
- 公平锁: 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
- 非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁.
注意:
-
操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构,来记录线程们的先后顺序.
-
公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
synchronized 是非公平锁.
1.6可重入锁 vs 不可重入锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
而 Linux 系统提供的 mutex 是不可重入锁.
二:CAS
CAS(Compare and Swap)是一种并发算法,用于解决多线程环境下的原子性操作问题。它是一种乐观锁的实现方式,通过比较共享变量的当前值与期望值是否相等来确定是否进行更新操作。
CAS操作包含三个参数:共享变量的内存地址、期望值和新值。它的执行步骤如下:
- 读取共享变量的当前值。
- 比较当前值与期望值是否相等。
- 如果相等,将共享变量的值设为新值。
- 如果不相等,说明其他线程已经修改了共享变量的值,当前操作失败,需要重新读取最新的值,并重新进行比较和更新。
CAS操作是原子性的,它不需要使用锁来保护共享变量,因此减少了锁的开销。同时,CAS操作的执行是非阻塞的,没有线程被挂起,增加了系统的并发性能。
然而,CAS操作也存在一些限制:
- ABA问题:如果共享变量的值在CAS操作前后被修改为相同的值,例如A->B->A,CAS操作无法检测到这种情况,可能会造成意外结果。
- 自旋次数过多:如果CAS操作失败,线程需要不断重试直到成功,过多的自旋会占用CPU资源。
为了解决ABA问题,通常使用版本号或标记位来标识共享变量的修改次数。每次修改时都会对版本号进行更新,即使值没有实际变化,也能保证CAS操作的正确性。
2.1 CAS的应用
2.1.1 实现原子类
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的,典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
CAS 操作有三个参数:目标值、期望值和新值。它的作用是比较目标值和期望值是否相等,如果相等,则将目标值设为新值。CAS 操作是原子的,即在执行过程中不会被其他线程干扰。
如果CAS操作返回true,表示成功更新了value的值,否则表示在CAS操作过程中,有其他线程修改了value的值,需要重新获取旧值并再次尝试CAS操作。
在代码中,‘CAS(value, oldValue, oldValue+1)’ 的意思是:将当前的 value 和 oldValue 进行比较,如果相等,则将 value 的值设为 oldValue+1。如果不相等,则循环继续执行直到比较成功。
这段代码的目的是实现线程安全的自增操作,即保证在多线程环境下每次调用 getAndIncrement 方法时 value 的值都会自增,并确保不会发生竞态条件(race condition)问题。
假设两个线程同时调用 getAndIncrement
- 两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)
- 线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值.
- 线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环,在循环里重新读取 value 的值赋给 oldValue
4) 线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作.
5) 线程1 和 线程2 返回各自的 oldValue 的值即可.
通过形如上述代码就可以实现一个原子类. 不需要使用重量级锁, 就可以高效的完成多线程的自增操作.
2.1.2 实现自旋锁
自旋锁伪代码:
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.2 ABA 问题
ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A,接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要:
- 先读取 num 的值, 记录到 oldNum 变量中.
- 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.
这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机。
2.2.1 ABA 问题引来的 BUG
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况.
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
正常的过程:
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
望更新为 50. - 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.
异常的过程
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100 !!
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作
这个时候, 扣款操作被执行了两次!!! 都是 ABA 问题搞的鬼!!
解决方案:
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
- CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候:
-
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
-
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
对比理解上面的转账例子:
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.我们期望一个线程执行 -50 成功, 另一个线程 -50 失败,为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.
- 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100,
版本号为 1, 期望更新为 50. - 线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100, 版本号变成3.
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读到的版本号为 1, 版本小于当前版本, 认为操作失败.
三:Synchronized 原理
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到的自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
3.1 Synchronized加锁工作过程
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
- 偏向锁
第一个尝试加锁的线程, 优先进入偏向锁状态。
偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销),如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
举个栗子理解偏向锁:
假设男主是一个锁, 女主是一个线程. 如果只有这一个线程来使用这个锁, 那么男主女主即使不领证结婚(避免了高成本操作), 也可以一直幸福的生活下去.
但是女配出现了, 也尝试竞争男主, 此时不管领证结婚这个操作成本多高, 女主也势必要把这个动作完成了, 让女配死心.
- 轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现.
- 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
- 如果更新成功, 则认为加锁成功
- 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源,因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了,也就是所谓的 “自适应”
- 重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁,此处的重量级锁就是指用到内核提供的 mutex .
- 执行加锁操作, 先进入内核态.
- 在内核态判定当前锁是否已经被占用
- 如果该锁没有占用, 则加锁成功, 并切换回用户态.
- 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
- 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.
3.1.1 其他的优化操作
- 锁消除:编译器和JVM 判断锁是否可消除. 如果可以, 就直接消除.
那么什么是 "锁消除"呢?
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销.
- 锁粗化:一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
锁的粒度: 粗和细
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁,但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.
举个栗子理解锁粗化:
滑稽老哥当了领导, 给下属交代工作任务:
方式一:
- 打电话, 交代任务1, 挂电话.
- 打电话, 交代任务2, 挂电话.
- 打电话, 交代任务3, 挂电话.
方式二:
- 打电话, 交代任务1, 任务2, 任务3, 挂电话.
显然, 方式二是更高效的方案.
可以看到, synchronized 的策略是比价复杂的, 在背后做了很多事情, 目的为了让程序猿哪怕啥都不懂,也不至于写出特别慢的程序.
四 Callable 接口
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.
4.1 版本1
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 不使用 Callable 版本:
static class Result {
public int sum = 0;
public Object lock = new Object();
}
public static void main(String[] args) throws InterruptedException {
Result result = new Result();
Thread t = new Thread() {
@Override
public void run() {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
synchronized (result.lock) {
result.sum = sum;
result.lock.notify();
}
}
};
t.start();
synchronized (result.lock) {
while (result.sum == 0) {
result.lock.wait();
}
System.out.println(result.sum);
}
}
下面对这段代码进行分析:
首先,我们在主线程中创建了一个Result对象result,并启动了一个新的线程t。在线程t的run方法中,使用循环将1到1000进行累加,并将结果存储到sum变量中。
然后,使用synchronized关键字将对result.lock对象进行同步处理。在主线程中,首先使用while循环来判断result.sum是否为0,如果为0,则调用result.lock对象的wait()方法,将主线程挂起。当线程t完成累加后,通过synchronized同步块获取到result.lock对象的锁,并将sum的值赋给result.sum,并调用result.lock对象的notify()方法唤醒主线程。主线程被唤醒后,输出result.sum的值。
可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错.
4.2 版本2
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 使用 Callable 版本:
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 1000; 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 和 FutureTask 之后, 代码简化了很多, 也不必手动写线程同步代码了.
理解 Callable:
- Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,Runnable,描述的是不带返回值的任务.
- Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果,因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
- FutureTask 就可以负责这个等待结果出来的工作.
理解 FutureTask:
- 想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是FutureTask.后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没.
FutureTask是RunnableFuture接口的一个实现类,并且实现了Runnable接口。RunnableFuture接口继承了Runnable和Future接口。由于FutureTask实现了Runnable接口,因此它可以被提交给线程池执行,同时又可以获取任务的返回结果。