序言
当我们启动一个应用程序的时候,根据不同应用场景的需要,主线程通常需要承担很多串行任务,比如:
- 更新数据库
- 读取一个文件
- 执行一些计算
- 访问一个 Web 服务
- 接收一个用户的输入信息
- 显示一个针对某用户的响应信息
- 等等
若每个操作的耗时都只是毫秒级的话,其实没有必要引人额外的执行流程,单线程就已经足够用了。
然而在大多数实际的应用程序中,很多操作的执行速度并没有这么快,某些计算的执行时间甚至需要几秒到几分钟不等。例如,某些需要从 Web 服务中获取数据的请求可能会遭遇网络延迟,所以执行线程就只好等待对端响应到达后才能继续执行。当单线程的应用程序遇到这种情况时,由于主线程被挂起在某个操作上,所以该应用程序的用户将无法与之进行交互或中断其当前任务的执行。
发生这种情况自然是我们不愿意看到的,因为用户的体验不好,为了让用户获得更好的体验,我们希望应用程序应当拥有这种能力——能够执行的更快,以使得请求响应时间缩短。
这个时候,就我们就想到了使用多线程程序,不过编写多线程程序之前,我们得先了解一些概念。
并发与并行
《深入理解计算机系统》一书中的描述:
并发(Concurrency
):若进程 B 的开始时间是在进程 A 的开始时间与结束时间之间,我们就说 A 和 B 是并发的。
并行(Parallel Execution
):并发的真子集,指同一时间两个进程运行在不同的机器上或者同一个机器不同的核心上。
打个比方,就像我们早上起来刷牙及烧水(用与洗脸洗头发)。
若是串行执行,必须一件一件执行:刷牙后再去烧水,要等水烧开。
若是并发执行,你可以先去烧水,在烧水的过程中进行刷牙操作,刷完牙水基本就开了。
若是并行执行,烧水的动作和你刷牙的动作同时发生,毕竟你可以左手拿着牙刷刷牙,右手拿着壶装满水去烧。
换而言之,上面把人当作了一个机器,而人的左右手充当了不同的核心。
进程和线程
进程:进程是资源分配的最小单位,指内存中已运行的应用程序。每个进程都有一个独立的内存空间,一个应用程序可以同时运行多个进程,进程也是程序的一次执行过程,是系统运行程序的基本单位。
线程:线程是 CPU 调度的最小单位,线程是进程中的一个执行单元,负责当前进程中程序的执行,一个进程至少有一个线程,有多个线程的进程应用程序被叫做多线程程序。
换而言之,进程(颗粒大)和线程(颗粒小)都是一个时间段的描述,是 CPU 工作时间段的描述,不过其颗粒大小不同。
线程创建方式
在 Java 中,线程类的创建方式存在以下三种:
- 继承
Thread
类 - 实现
Runnable
接口 - 实现
Callable
并使用FutureTask
继承 Thread 类
实现多线程的第一种方法是就是让类继承Thread
类,重写其run
方法即可。
在Java 中,使用java.lang.Thread
类表示线程,所有的线程都必须是Thread
类或其子类的实例。
此方式的具体实现步骤如下:
① 定义Thread
类的子类,并重写其run
方法,该方法的方法体就代表了线程需要完成的任务,该方法也被称为线程执行体;
② 创建Thread
类子类的实例(对象)
③ 调用子类对象的start
方法启动线程
具体代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23// 创建线程类
public class ThreadTask extends Thread {
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(i);
}
}
}
// 测试类
public class ThreadTest {
public static void main(String[] args) {
new ThreadTask().start();
}
}
// 结果
0
1
2
3
4
注
:若直接调用run
方法则执行的是一个普通方法,因为此时 JVM 并没有开启新的栈空间,而通过start
方法 JVM 会开辟一个新的栈空间,CPU 可以选择执行不同的栈空间。
Thread 类常用 API
构造方法:
public Thread()
:分配一个新的线程对象public Thread(String name)
:分配一个指定名字的新的线程对象public Thread(Runnable target)
:分配一个带指定目标的新的线程对象public Thread(Runnable target,String name)
:分配一个带指定目标新的线程对象并指定其名字。
常用方法:
public String getName()
:获取当前线程的名字public void run()
:此线程具体要执行的代码逻辑在这里定义public void start()
:让线程开始执行的方法,使 JVM 调用此线程的run
方法public static void sleep(long millis()
:是当前正在执行的线程暂停指定的毫秒数public static Thread currentThread()
:返回当前正在执行的线程对象的引用。
实现 Runnable 接口
实现多线程的第二种方法就是让类实现一个Runnable
接口,重写其run
方法即可。
此方式创建线程具体步骤如下:
① 定义Runnable
接口的实现类并重写其run
方法;
② 创建Runnable
接口的实现类的实例,并将此实例作为Thread
的target
传入以创建线程对象,该Thread
对象才是真正的线程对象
③ 调用线程对象的start
方法启动线程:
1 | // 创建 Runnable 接口实现类 |
Runnable
接口源码很简单,就是要求子类重写run
方法:1
2
3public interface Runnable {
public abstract void run();
}
实现 Callable 接口
实现多线程的第三种方法就是让类实现一个Callable
接口,重写其call
方法即可。
此方式创建线程具体步骤如下:
① 创建Callable
接口的实现类;
② 将实现类作为参数传入FutureTask
类的构造器中
③ 将FutureTask
对象作为参数传入Thread
的构造器中
③ 启动线程并获取执行结果
实现代码如下:1
2
3
4
5
6
7
8
9public class CallableTask implements Callable {
public String call() throws Exception {
for (int i = 0; i < 5; i++) {
System.out.println(i);
}
return "ok";
}
}
1 | public class CallableThreadTest { |
运行结果如下:1
2
3
4
5
60
1
2
3
4
ok
Callable 接口
Callable
接口和 Runnbale
一样代表着异步任务,但 Callable
有返回值且可抛出异常,其源码如下:1
2
3
4
public interface Callable<V> {
V call() throws Exception;
}
其中类型参数 V 代表返回值的类型,如 Callable<Integer>
表示返回 Integer
对象。
Future 接口
在 Java 并发包(JUC 包)中Future
代表着异步计算结果。
Future
中提供了一系列方法用来检查计算结果是否已经完成,也提供了同步等待 任务执行完成的方法,还提供了获取计算结果的方法等。
当计算结果 完成时只能通过提供的get系列方法来获取结果,如果使用了不带超时 时间的get方法,则在计算结果完成前,调用线程会被一直阻塞。另外 计算任务是可以使用cancel方法来取消的,但是一旦一个任务计算完 成,则不能再被取消了。
Future
接口的源码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44public interface Future<V> {
/**
* 尝试取消任务的执行
* <p>
* 如果当前任务已经完成或者任务已经被取消了,则尝试取消任务会失败;
* 如果任务还没被执行时调用了取消任务,则任务将永远不会被执行;
* 如果任务已经开始运行了,此时取消任务,则参数 mayInterruptIfRunning 将决定是否要将正在执行任务的线程中断(true 则中断,false 不中断)
* 当调用取消任务后,再调用 isDone()方法,后者会返回true,随后调用isCancelled()方法也会一直返 回true;
* 如果任务不能被取消,比如任务完成后已经被取消了,则该方法会返回false。
*/
boolean cancel(boolean mayInterruptIfRunning);
/**
* 如果任务在完成前被取消了, 则返回 true,否则返回 false
*/
boolean isCancelled();
/**
* 如果计算任务已经完成则返回 true,否则返回 false
* 需要注意的是,任务完成是指:
* 1.任务正常完成了
* 2.由于抛出异常而完成了
* 3.任务被取消了。
*/
boolean isDone();
/**
* 等待异步计算任务完成,并返回结果
* <p>
* 如果当前任务计算还没完成则会阻塞调 用线程直到任务完成;
* 如果在等待结果的过程中有其他线程取消了该任务,则调用线程抛出CancellationException异常;
* 如果在等待结果的 过程中有其他线程中断了该线程,则调用线程抛出InterruptedException 异常;
* 如果任务计算过程中抛出了异常,则调用线程会抛出 ExecutionException异常。
*/
V get() throws InterruptedException, ExecutionException;
/**
* 获取任务执行结果,相比get()方法多了超时时间
* <p>
* 当线程调用了该方法后,在任务结果没有计算出来前调用线程不会一直被阻塞,而是会在等待 timeout 个 unit 单位的时间后抛出 TimeoutException 异常后返回。
* 添加超时时间避免了调用线程死等的情况,让调用线程可以及时释放。
*/
V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
}
FutureTask 类
FutureTask
代表了一个可被取消的异步计算任务。
FutureTask
的继承结构很简单,其源码如下:1
public class FutureTask<V> implements RunnableFuture<V>
可以看到,FutureTask
类只实现了RunnableFuture
接口,我们再来看看它的的源码:1
2
3public interface RunnableFuture<V> extends Runnable, Future<V> {
void run();
}
从源码中可以看到RunnableFuture
接口既继承了Runnable
接口又继承了Future
接口,那么FutureTask
类也需要分别实现Runnable
接口和Future
接口的方法。
FutureTask
构造器如下:
1 | // 构造 Callable 接口的对象 |
需要注意的是:FutureTask
任务的结果只有当任务完成后才能获取,并且只能通过get
系列方法获取,当结果还没出来时,线程调用get
系列方法会被阻塞。
另外,一旦任务被执行完成,任务将不能重启,除非运行时使用了runAndReset
方法。
最后,FutureTask
类型的任务还可以被提交到线程池执行。
总结
- 接口可以避免单继承的局限性
- 接口代码可以被多个线程共享,代码和线程独立
- 线程池只能放入实现 Runable 或 Callable 接口的线程
- 接口更适合多个相同的程序代码的线程去共享同一个资源
注意哦
:在 Java 中,每次程序的运行至少启动 2 个线程,一个是 main 线程,一个是垃圾收集线程
线程状态
和人一样,线程也有生老病死,其有如下 6 种状态(Thread.State 枚举源码中定义):
- New (新创建)
- Runnable (可运行)
- Blocked (被阻塞)
- Waiting (等待)
- Timed waiting (计时等待)
- Terminated (被终止)
这些状态的图示如下,后面将详细介绍:
注
:通过getState
方法可以获得线程的当前状态。
新创建线程
当用new
操作符创建一个新线程(如 new Thread(r)
)后,该线程还没有开始运行,因为运行之前还有一些基础工作要做。
可运行线程
一旦调用start
方法, 线程就处于runnable
状态。
一个可运行的线程可能正在运行也可能没有运行, 这取决于操作系统给线程提供运行的时间。
一旦一个线程开始运行,它不用始终保持运行状态,这代表着运行时的线程可以被中断。
运行中的线程被中断的目的是为了让其他线程获得运行机会。线程调度的细节依赖于操作系统提供的服务。抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完,操作系统剥夺该线程的运行权,并给另一个线程运行的机会。当选择下一个线程时, 操作系统则会考虑线程的优先级。
现在所有的桌面以及服务器操作系统都使用抢占式调度。但是,像手机这样的小型设备可能使用协作式调度。在这样的设备中,一个线程只有在调用yield
方法、或者被阻塞或等待时,线程才失去控制权。
在具有多个处理器( CPU )的机器上,每一个 CPU 运行一个线程,因此可以有多个线程并行运行。
当然,若线程的数目多于处理器的数目,调度器依然采用时间片机制。
被阻塞线程和等待线程
当线程处于被阻塞或等待状态时,它暂时不活动,即不运行任何代码且消耗最少的资源,除非线程调度器重新激活它,其细节取决于线程怎样达到非活动状态的。
- 阻塞线程:当一个线程试图获取一个内部的对象锁,而该锁却被其他线程所持有,则该线程进人阻塞状态。当所有其他线程释放该锁, 并且线程调度器允许本线程持有它的时候, 该线程才变成非阻塞状态。
- 等待线程:当线程等待另一个线程通知调度器一个条件时,它自己进入等待状态。线程的被阻塞状态与等待状态是有很大不同的。 出现这种情况一般在进行如下操作时发生:
- 调用
Object
类的wait
方法时 - 调用
Thread
类的join
方法时 - 调用
java.util.concurrent
库中的Lock
或Condition
时
- 调用
- 计时等待线程:调用具有超时参数的方法将导致线程进人计时等待状态。 这一状态将一直保持到超时期满或者其接收到适当的通知。 带有超时参数的方法有:
Thread
类的sleep
方法Object
类的wait
方法Thread
类的join
方法Lock
,tryLock
及Condition.await
的计时版
被终止的线程
线程会因如下两个原因之一而被终止:
- 因为
run
方法正常退出而自然死亡。 - 因为一个没有捕获的异常终止了
run
方法而意外死亡。
比如调用线程的stop
方法将杀死一个线程, 该方法抛出ThreadDeath
错误对象 ,由此杀死线程(此方法已过时,请不要使用)。
线程属性
线程的属性包括(部分):
- 线程优先级
- 守护线程
- 线程组
- 处理未捕获异常的处理器
线程优先级
Java 中的每个线程都存在优先级。
每当线程调度器有机会选择新线程时, 首先选择具有较高优先级的线程。
通过setPriority(int newPriority)
方法可以改变线程的优先级,数字在1~10
之间,默认为 5 。
注
:默认情况下,一个线程继承它的父线程的优先级。
守护线程
可以通过调用t.setDaemon(true);
方法将线程转换为守护线程(daemon thread
)。守护线程的唯一用途是为其他线程提供服务。计时线程就是一个例子。
守护线程应该永远不去访问固有资源, 如文件、 数据库, 因为它会在任何时候甚至在一个操作的中间将发生中断。
线程同步
在大多数多线程应用中, 两个或两个以上的线程需要共享对同一数据的存取。
若两个线程存取相同的对象,并且每一个线程都调用了一个修改该对象变量的方法, 将会发生什么呢?
可以想象, 线程彼此踩了对方的脚,对象变量的值可能是一个错误数据(此情况通常称为竞争条件)
为了解决此情况,需要使用同步机制。
竞争条件的一个栗子
若不用同步机制会发生什么?请看一个栗子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52public class Bank {
/**
* 银行账户数组
*/
private final double[] accounts;
/**
* @param n 该银行的账户数
* @param initBalance 每个账户的初始金额
*/
public Bank(int n, double initBalance) {
this.accounts = new double[n];
// 给每个账户设置初始金额
Arrays.fill(accounts, initBalance);
}
/**
* 从索引为 from 的账户转出 amount 的金钱到索引为 to 的账户
*
* @param from
* @param to
* @param amount
*/
public void transfer(int from, int to, double amount) {
// from 账户钱不够无法转账,直接返回
if (accounts[from] < amount) {
return;
}
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.printf("从 %d 号账户往 %d 号账户转了%6.2f元钱, ", from, to, amount);
accounts[to] += amount;
System.out.printf("转账成功后银行共有%10.2f元钱\n", getTotalBalance());
}
/**
* 获取银行存钱的总金额
*
* @return
*/
public double getTotalBalance() {
double sum = 0;
for (double account : accounts) {
sum += account;
}
return sum;
}
public int size() {
return accounts.length;
}
}
在上面:我们定义了一个Bank
类,该银行的构造方法指定了账户数量和账户的初始金额,其有一个转账的方法transfer
,可以从一个账户往另一个账户转指定数量的金钱,转账完成后输出谁给谁转了多少钱,还输出银行存钱的总数。
现在我们定义一个测试方法,来测试该银行的转账操作:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26public class UnsynchBankTest {
public static void main(String[] args) {
// 设置该银行有 100 个初始金额为 1000 的账户
Bank bank = new Bank(100, 1000);
for (int i = 0; i < 100; i++) {
// 设置转账方
int fromAccount = i;
new Thread(() -> {
try {
while (true) {
// 设置收账方为随机的一个银行账户
int toAccount = (int) (bank.size() * Math.random());
// 设置转账金钱为 1000 以内的随机数
double account = 1000 * Math.random();
// 执行转账
bank.transfer(fromAccount, toAccount, account);
// 转账后线程休眠 0~10 秒
Thread.sleep((int) (10 * Math.random()));
}
} catch (InterruptedException e) {
}
}).start();
}
}
}
具体说明见注释,下面为部分测试结果:1
2
3
4
5
6
7
8Thread[Thread-80,5,main]从 80 号账户往 92 号账户转了 19.86元钱, 转账成功后银行共有 98318.08元钱
Thread[Thread-5,5,main]从 5 号账户往 44 号账户转了257.04元钱, 转账成功后银行共有 98318.08元钱
从 97 号账户往 43 号账户转了 87.14元钱, 转账成功后银行共有 99274.05元钱
Thread[Thread-62,5,main]从 62 号账户往 94 号账户转了442.61元钱, 转账成功后银行共有 99274.05元钱
Thread[Thread-7,5,main]从 7 号账户往 10 号账户转了245.80元钱, 转账成功后银行共有 99274.05元钱
从 18 号账户往 81 号账户转了725.95元钱, 转账成功后银行共有 100000.00元钱
Thread[Thread-19,5,main]从 19 号账户往 28 号账户转了859.04元钱, 转账成功后银行共有 100000.00元钱
Thread[Thread-90,5,main]从 90 号账户往 28 号账户转了310.71元钱, 转账成功后银行共有 100000.00元钱
当程序运行时,不清楚在某一时刻某一银行账户中有多少钱。 但是所有账户加起来的总金额应该保持不变, 因为代码所做的不过是从一个账户转移钱款到另一个账户。
可是结果出现了错误。在某些交易中,银行的余额保持在$100000
, 这是正确 的, 因为共100
个账户, 每个账户$1000
。 但是, 过一段时间, 余额总量有轻微的变化。当运行这个程序的时候,会发现有时很快就出错了,有时很长的时间后余额发生混乱。
这种银行你会存钱嘛?
肯定不会呀,那么——程序为什么会出错?
竞争栗子解析
前面的程序有几个线程更新银行账户余额。一段时间之后,错误不知不觉地出现了,总额要么增加,要么变少。当两个线程试图同时更新同一个账户的时候,这个问题就出现了。 假定两个线程同时执行下面的代码:1
accounts[to] += amount;
由于其不是原子操作。 该代码可能被这么处理:
① 将accounts[to]
加载到寄存器。
② 增加amount
③ 将结果写回 accounts[to]
现在,假定第 1 个线程执行步骤 ① 和 ②, 然后, 它被剥夺了运行权。假定第 2 个线程被return accounts.length;
唤醒并修改了accounts
数组中的同一项。然后,第 1 个线程被唤醒并完成其第 3 步。
于是,这一动作擦去了第二个线程所做的更新,因此总金额不再正确。具体见后面的图。
可以具体看一下执行的类中的每一个语句的虚拟机的字节码,运行命令javap -c -v Bank
对Bank.class
文件进行反编译。
代码行accounts[to] += amount;
将被转换为下面的字节码:
1 | aload_0 |
这些代码的含义无关紧要。重要的是增值命令是由几条指令组成的, 执行它们的线 程可以在任何一条指令点上被中断。
出现这一错误的可能性有多大呢? 这里通过将打印语句和更新余额的语句交织在一起执行, 增加了发生这种情况的机会。
若删除打印语句,错误的风险会降低一点,因为每个线程在再次睡眠之前所做的工作很少,调度器在计算过程中剥夺线程的运行权可能性很小。但是,错误的风险并没有完全被消失。比如在负载很重的机器上运行许多线程,即使删除了打印语句,程序依然会出错。这种错误可能会几分钟、几小时或几天出现一次,但总会出错,出错可不好呀!
我们发现了真正的问题是transfer
方法的执行过程中可能会被其他线程打断。 若能够确保线程在失去控制之前方法运行完成, 那么银行账户对象的状态永远不会出现错误。
锁对象
Java中有 3 种方法防止代码块受并发访问的干扰:
Lock
锁synchronized
同步代码块synchronized
同步方法
以上都是可重入锁,那么什么是可重入锁呢?
什么是可重入(互斥)锁?
计算机科学中,可重入互斥锁(reentrant mutex)是互斥锁的一种,同一线程对其多次加锁不会产生死锁。可重入互斥锁也称递归互斥锁(recursive mutex)或递归锁(recursive lock)。
若对已经上锁的普通互斥锁进行“加锁”操作,其结果要么失败,要么会阻塞至解锁。而若换作可重入互斥锁,当且仅当尝试加锁的线程就是持有该锁的线程时,类似的加锁操作就会成功。可重入互斥锁一般都会记录被加锁的次数,只有执行相同次数的解锁操作才会真正解锁。
疑惑:可重入锁比不可重入锁更好嘛?
可重入锁相对于不可重入锁来说,更加灵活和安全。因此,在大多数情况下,使用可重入锁更好一些。
首先,可重入锁可以避免死锁的发生,而不可重入锁会增加死锁的风险。在多线程程序中,如果一个线程已经持有了一个不可重入锁,但是需要再次获取同一把锁,那么这个线程就会被自己所持有的锁所阻塞,导致死锁的发生。而如果这个锁是可重入锁,那么这个线程就可以自由地再次获取这个锁,而不会被自己所持有的锁所阻塞,从而避免了死锁的发生。
其次,可重入锁的实现方式相对来说更加灵活。synchronized关键字是可重入锁,而且它是Java语言内置的,使用起来非常方便。而Lock接口的实现类ReentrantLock也是可重入锁,它提供了更加灵活的加锁和解锁方式,可以控制锁的粒度,从而提高程序的性能。
最后,可重入锁可以减少锁竞争的次数,从而提高程序的并发性能。如果使用不可重入锁,那么每次获取锁的时候都需要进行一次上锁操作,释放锁的时候也需要进行一次解锁操作。而如果使用可重入锁,同一个线程多次获取锁的时候,只需要进行一次上锁操作,释放锁的时候也只需要进行一次解锁操作,减少了锁竞争的次数,从而提高了程序的并发性能。
综上所述,可重入锁相对于不可重入锁来说更加灵活、安全和高效,因此在大多数情况下,使用可重入锁更好一些。
Lock 锁
Lock
是java.util.concurrent.Locks
的一个接口,它提供了比使用synchronized
方法和语句可获得的更广泛的锁定操作,其中有 2 个方法:
void lock()
:获取锁。void unlock()
:释放锁。
ReentrantLock
为该接口的实现类,使用步骤如下:
① 在成员位置创建一个ReentrantLock
对象;
② 在可能出现安全问题的代码前调用Lock
接口中的lock
方法获取锁;
③ 在可能出现安全问题的代码后调用Lock
接口中的unlock
方法释放锁;
用ReentrantLock
保护代码块的例子如下:1
2
3
4
5
6
7
8
9
10private Lock myLock = new ReentrantLock();
public void test() {
myLock.lock(); // 一个 ReentrantLock 对象加锁
try {
// 逻辑代码
} finally {
myLock.unlock(); // 执行完成或抛出异常该锁都打开
}
}
这一结构确保任何时刻只有一个线程进入临界区。
一旦一个线程封锁了锁对象, 其他任何线程都无法通过lock
语句。
当其他线程调用lock
时, 它们将被阻塞, 直到第一个线程释放锁对象。
注意哦
:把解锁操作括在finally
子句是至关重要的。若在临界区的代码抛出异常, 锁必须被释放,否则其他线程将永远阻塞。
现在让我们使用一个锁来保护Bank
类的transfer
方法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22public class Bank {
....
private Lock bankLock = new ReentrantLock();
....
public void transfer(int from, int to, double amount) {
bankLock.lock();
try {
//from账户钱不够无法转账,直接返回
if (accounts[from] < amount) {
return;
}
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.printf("从 %d 号账户往 %d 号账户转了%6.2f元钱, ", from, to, amount);
accounts[to] += amount;
System.out.printf("转账成功后银行共有%10.2f元钱\n", getTotalBalance());
} finally {
bankLock.unlock();
}
}
....
}
测试类不变,测试结果如下:1
2
3
4
5
6Thread[Thread-44,5,main]从 44 号账户往 28 号账户转了819.51元钱, 转账成功后银行共有 100000.00元钱
Thread[Thread-90,5,main]从 90 号账户往 28 号账户转了546.29元钱, 转账成功后银行共有 100000.00元钱
Thread[Thread-51,5,main]从 51 号账户往 1 号账户转了471.16元钱, 转账成功后银行共有 100000.00元钱
Thread[Thread-65,5,main]从 65 号账户往 31 号账户转了815.79元钱, 转账成功后银行共有 100000.00元钱
Thread[Thread-99,5,main]从 99 号账户往 66 号账户转了491.02元钱, 转账成功后银行共有 100000.00元钱
Thread[Thread-15,5,main]从 15 号账户往 97 号账户转了799.86元钱, 转账成功后银行共有 100000.00元钱
现在结果符合预期结果,为什么会这样呢?
假定一个线程调用transfer
方法, 在执行结束前被剥夺了运行权。此时若第二个线程也调用transfer
方法, 由于第二个线程不能获得锁, 将在调用lock
方法时被阻塞。它必须等待第一个线程完成transfer
方法的执行之后才能再度被激活。
此时,当第一个线程释放锁时,那么第二个线程才能开始运行。如下图:
注意哦:
每一个Bank
对象有自己的ReentrantLock
对象。 若两个线程试图访问同一个Bank
对象, 因为加了锁,所以程序以串行方式提供服务。但是, 若两个线程访问不同的Bank
对象, 则每一个线程的将得到不同的锁对象,它们都不会发生阻塞。
此种锁是可重入的,即线程可以重复地获得已经持有的锁。可重入锁将保持一个计数(count
)来跟踪对lock
方法的嵌套调用。线程在每一次调用lock
后都应调用unlock
来释放锁。 由于这一特性, 被一个锁保护的代码可以调用另一个使用相同的锁的方法。
例如,transfer
方法调用getTotalBalance
方法时会对bankLock
对象再次加锁, 此时bankLock
对象的持有计数变为2
。 当getTotalBalance
方法退出的时候,持有计数变回1
。当transfer
方法退出的时候,持有计数又变为0
,此时线程将释放锁。
内部锁:使用 synchronized 关键字
大多数情况下, 并不需要像Lock
那样高度的的锁定控制, 我们可以使用一种嵌人到 Java 语言内部的锁机制。 从 1.0 版开始, Java 中的每一个对象都有一个内部锁。
若使用一个synchronized
关键字给对象加锁同步一个代码块,那么该代码块会被上锁。
若一个方法用 synchronized
关键字声明, 那么对象的锁将保护整个方法。
下面的 3 种方法等价:1
2
3
4
5
6
7
8
9
10
11// 同步代码块
Object obj = new Object();
public void method1(){
synchronized(obj){
try{
....
}finally{
....
}
}
}
1 | // 同步方法 |
1 | // Lock 锁 |
synchronized
同步方法加锁的对象为当前对象,而synchronized
同步代码块加锁的对象为创建的Object
对象,当然我们也可以不创建该对象直接使用this
来加锁当前对象以简化代码量,如将同步代码块改成synchronized(this)
。
也就是说,现在我们可以简单地将Bank
类的transfer
方法声明为synchronized
, 而不是使用一个显式的锁。
将静态方法声明为synchronized
也是合法的。 若调用这种方法, 该方法获得相关的类对象的内部锁。 例如,若 Bank 类有一个静态同步的方法, 那么当该方法被调用时,Bank.class
对象的锁被锁住。
synchronized VS Lock
synchronized | Lock |
---|---|
Java 内置关键字 | Java 类 |
无法判断锁的状态 | 可以判断锁的状态 |
自动释放锁 | 手动 |
非公平锁不可中断 | 是否公平可设置 |
适合锁少量代码 | 适合锁大量代码 |
可重入 | 可重入 |
volatile 变量
有时仅仅为了读写一个或两个实例变量就使用同步,显得开销过大了。使用现代的处理器与编译器,出错的可能性很大:
- 多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。 结果使得运行在不同处理器上的线程可能在同一个内存位置取到不同的值。
- 编译器可以改变指令执行的顺序以使吞吐量最大化。这种顺序上的变化不会改变代码语义,编译器假定内存中的值仅仅在代码中有显式的修改指令时才会改变。然而多线程中,内存的值是可以被另一个线程改变的!
volatile
关键字为实例变量的同步访问提供了一种免锁机制。
若声明一个变量为volatile
, 那么编译器和虚拟机就知道该变量是可能被另一个线程并发更新的,会保证修改的值会立即被更新到主存(内存),当有其他线程需要读取时,它会去内存中读取新值。
死锁
举例:线程 A 持有资源 2,线程 B 持有资源 1,在线程 A、B 都没有释放自己所持有资源的情况下(锁未释放),它们都想同时获取对方的资源,因为资源 1、2 都被锁定,两个线程都会进入相互等待的情况,这种情况称为死锁。
线程通信
线程间通信常用方式如下:
- 休眠唤醒方式
Object
中的wait
、notify
、notifyAll
方法Condition
的await
、signal
、signalAll
方法
CountDownLatch
:用于某个线程 A 等待若干个其他线程执行完之后,它才执行CyclicBarrier
:一组线程等待至某个状态之后再全部同时执行Semaphore
:用于控制对某组资源的访问权限
Object 中线程状态方法
Object
类中有一些和线程相关的方法:
void wait()
:调用此方法将导致线程进入等待状态直到它被通知;void wait(long millis)
:调用此方法将导致线程进入等待状态直到它被通知或经过指定的时间;void wait(long millis,int nanos)
:调用此方法将导致线程进入等待状态直到它被通知或经过指定的时间;void notify()
:随机选择一个在该对象上调用wait
方法的线程并解除其阻塞状态;void notifyAll()
:解除所有在该对象上调用wait
方法的线程的阻塞状态;
上面的方法都只能在同步方法或同步代码块内部调用, 若当前线程不是对象锁的持有者,都将拋出一个IllegalMonitorStateException
异常。
线程池
new Thread 缺点
当我们准备使用一个线程的时就通过new Thread
新建一个线程,这么做没毛病但却存在如下问题:
- 缺少其他功能,如定时执行、线程中断
- 每次新建和销毁线程非常耗费资源和时间,性能差
- 线程缺乏统一管理,可能无限制的新建线程,它们相互竞争,可能占用过多资源导致死机
为了解决这些问题,引入了线程池来代替 new Thread
。
简介
线程池:一个容纳多个线程的容器,其中的线程可以反复使用,省去了频繁创建线程对象的操作,提高资源的利用率。
合理使用线程池能够带来如下好处:
- 提供定时执行、单线程、并发数控制等功能
- 可重用存在的线程,减少线程创建、销毁开销,性能更好
- 可有效控制最大并发线程数,提高资源利用率,同时可避免过多资源竞争,避免阻塞
继承树结构
Java 线程池的核心实现类为 ThreadPoolExecutor,其继承关系如下图:
继承树结构各部分说明如下:
Executor
:作为 ThreadPoolExecutor 实现类的顶层接口,其提供了一种思想——将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供 Runnable 对象,将任务的运行逻辑提交到执行器 Executor 中,由 Executor 框架完成线程的调配和任务的执行部分ExecutorService
:继承 Executor 接口,该接口额外增加了一些能力:- 提供了管控线程池的方法,比如判断线程池的状态,停止线程池的运行
- 扩充执行任务的能力,补充可以为一个或一批异步任务生成 Future 的方法
AbstractExecutorService
:作为 ThreadPoolExecutor 上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可ThreadPoolExecutor
:最下层的实现类实现最复杂的运行部分,一方面维护自身的生命周期,另一方面同时管理线程和任务,使两者良好的结合从而执行并行任务
组成参数
使用线程池的常见做法是使用new ThreadPoolExecutor
,该类构造器参数包括:
corePoolSize
:核心线程数大小,不管它们创建以后是不是空闲的。线程池需要保持 corePoolSize 数量的线程,除非设置了 allowCoreThreadTimeOutmaximumPoolSize
:最大线程数,池中最多允许创建 maximumPoolSize 个线程keepAliveTime
:存活时间,若经过 keepAliveTime 时间后超过核心线程数的线程还没有接受到新的任务,那就回收unit
:keepAliveTime
参数的时间单位workQueue
:存放待执行任务的队列,当提交的任务数超过核心线程数大小后,再提交的任务就存放在这里threadFactory
:线程工厂,创建自定义的线程工厂,可以自定义线程名称,当进行虚拟机栈分析时,通过名称可知线程来源池,不会懵逼handler
:拒绝策略,当队列任务已满且最大线程数的线程都在工作时,继续提交的任务线程池就处理不了,应该执行怎么样的拒绝策略
运行原理
使用
Java 里面的线程池的继承图如下:
线程的顶级接口是java.util.concurrent.Exector
,但是严格意义上讲Executor
并不是一个线程池,而只是一个执行线程的工具,真正的线程池接口是java.util.concurrent.ExecutorService
。
要配置一个线程池比较复杂,所以官方提供了一个Executors
工程类来创建线程池对象。该类有个创建线程池的方法:
public static ExecutorService newFixedThreadPool(int nThreads)
:返回线指定线程数量的程池对象Future<?> submit(Runnable task)
:获取线程池中的某一个线程对象,并执行传递进的Runnable
使用线程池中线程对象的步骤:
① 创建线程池对象
② 创建Runnable
接口实现类
③ 提交实现类
④ 关闭线程池
示例代码如下:1
2
3
4
5
6
7
public void test() {
ExecutorService executorService = Executors.newFixedThreadPool(2);
executorService.submit(() -> System.out.println(Thread.currentThread() + "创建了一个线程"));
executorService.submit(() -> System.out.println(Thread.currentThread() + "又创建了一个线程"));
executorService.submit(() -> System.out.println(Thread.currentThread() + "还创建了一个线程"));
}
运行结果如下:1
2
3Thread[pool-1-thread-1,5,main]创建了一个线程
Thread[pool-1-thread-2,5,main]又创建了一个线程
Thread[pool-1-thread-1,5,main]还创建了一个线程
可以看到第一个任务执行完成后将线程返回给线程池,之后第三个任务又用到了该线程,使得资源能重复利用。
参考
- Java 线程池实现原理及其在美团业务中的实践
- Cay S. Horstmann. Java 核心技术卷一 [M]. 机械工业出版社,2016