1. 简介
- 先来引入多线程编程中存在的问题。下面是一个例子(多个线程同时更新计数器):
/*
* 多个线程同时更新计数器(模拟多线程中存在的问题)
*/
public class Temp_1 {
public static void main(String[] args) {
// 连续模拟操作 10 次
for(int i = 0;i < 10;i++) {
update_counter_demo();
}
}
// 构造工作者线程,模拟多线程同时更新计数器
public static void update_counter_demo(){
Counter counter = new Counter(0);
int worker_thread_num = 10; // 10 个工作者线程
Thread[] workers = new Thread[worker_thread_num];
for(int j = 0;j < worker_thread_num;j++) {
workers[j] = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(100); // 先等待 100 毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}
for(int i = 0;i < 100;i++) { // 执行累加 100 次
counter.plusOne();
}
}
});
}
// 启动所有工作者线程
for(int i = 0;i < worker_thread_num;i++) {
workers[i].start();
}
// 等待线程结束
for(int i = 0;i < worker_thread_num;i++) {
try {
workers[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 打印计数器数值
System.out.println(counter);
}
/* 简单计数器 */
private static class Counter {
private int count;
public Counter(int count) {
this.count = count;
}
/* 计数器加 1 */
public void plusOne() {
count++;
}
@Override
public String toString() {
return "[ count is " + count + " ]";
}
}
}
执行结果如下:
[ count is 838 ]
[ count is 932 ]
[ count is 995 ]
[ count is 969 ]
[ count is 827 ]
[ count is 979 ]
[ count is 1000 ] // 正确结果
[ count is 1000 ] // 正确结果
[ count is 820 ]
[ count is 998 ]
可以发现,连续模拟操作 10 次,其中 碰巧 有 2 次的运作结果是正确的!我们知道,错误结果是 未同步多个线程对共享变量的操作 导致的。
- 多个线程操作共享变量时存在 可见性 问题,线程 A 对计数器的累加结果对线程 B 不可见。而对于计数器的累加操作,当前操作是基于前一次的操作结果的,各个操作结果前后彼此关联,如果每一次累加操作中的 ‘读取操作’ 都是基于前一个累加操作的结果进行,就不会出现错误结果,这便是将多个相同操作串行执行。
- 可以将累加操作简化为 3 条指令(当然实际情况可能复杂的多):
- 读取操作(读取计数器的数值到线程私有内存)
- 执行加 1 运算
- 写入操作(将私有内存的数值写回到主内存)
- 当多个线程同时执行相同的累加操作(同一个方法即 plusOne() )时,由于线程调度的原因,多个线程的累加操作互相交织在一起,多个线程轮流使用 CPU 时间片(即 线程调度)其本身并不会造成问题,问题在于这多个线程的写入操作相互覆盖以及写入操作之前的读取操作 即步骤 1 的结果不可信(这种 先读取后写入的方式中,写入操作基于一个不可靠的读取操作,即 可见性问题),如下图所示(3 个线程各执行一次累加操作后,线程 D 读取到的计数为 1 !),多个线程的累加操作步骤相互穿插在一起,它们彼此之间没有协作,没有交流/通信,各自做各自的事情,却彼此相互影响!
- 解决问题的方式:引入 ‘互斥操作’,同一时刻只允许一个线程执行累加操作(即 互斥性),这样使得多个线程中的累加操作串行执行,各个线程中的相同操作指令不再交叉(使得组成同一个累加操作的一组指令被聚合在一起,类似于一个原子操作执行),如下图:
- 原子操作。原子操作是指一组不可分割的操作,且它们要么都执行成功,要么都不执行,即执行不能被中断。这里要面对多个线程操作共享数据的问题,即怎样 使得分散在多个线程中的会相互影响的操作能够不再彼此干扰对方。
- 互斥性。互斥性是指一组互相对立的操作,例如:操作 X / Y / Z 为一组互斥操作(它们可能是位于多个线程中的相同操作,例如上述的多个线程执行累加计数器的操作),则当操作 X 执行时,这一组互斥操作中的其它所有操作 Y 和 Z 都不允许被执行,直到操作 X 执行完毕(不论执行结果是否异常),互斥性提供了一种粗粒度的原子操作保障,操作 X 可能执行异常即执行失败,当前线程也可能被线程调度机制打断,导致上下文切换。
- Java 语言提供了最基本的互斥操作保障,即 synchronized 关键字,相当于排它锁的效果,它为一段代码(一组指令)提供了排它性的边界,同一时刻只允许一个线程进入边界,并且直到该线程退出边界,其它线程才能再次进入。修改上述程序中的计数器 Counter 类,代码如下(仅仅使用 synchronized 关键字修饰执行累加操作的方法,使得分散在多个线程中的累加操作被串行执行):
- 经常与 synchronized 一起提到的还有 volatile 关键字。但是,在这个多个线程执行写入操作的场景中,volatile 关键字显得力不从心,它仅仅只能保证线程能读到计数器的最新的数值(即 每次从主内存读取数据),但并不能阻止写入覆盖,也不能保证读取操作的可靠性。
/* 简单计数器 */
private static class Counter {
private int count;
public Counter(int count) {
this.count = count;
}
/* 计数器加 1,使用 synchronized 关键字修饰*/
synchronized public void plusOne() {
count++;
}
@Override
public String toString() {
return "[ count is " + count + " ]";
}
}
再次运行程序,执行结果如下:
[ count is 1000 ]
[ count is 1000 ]
[ count is 1000 ]
[ count is 1000 ]
[ count is 1000 ]
[ count is 1000 ]
[ count is 1000 ]
[ count is 1000 ]
[ count is 1000 ]
[ count is 1000 ]
可见运行结果正确,synchronized 关键字为我们提供保障。
- 说完 synchronized 关键字,再来看看 Java 中的 ‘等待 / 通知’机制,即 Object 类中提供的 wait() / notify() 方法。
- 我们知道 wait() / notify() 必须结合 synchronized 一起使用, 使得不同线程中运行的 synchronized 方法或代码块可以相互通信/交流(交流方式:线程主动等待 与 被动唤醒)(wait() 和 notify() 方法必须配合使用) 。wait() 方法提供的等待机制将等待的线程排队,并且处理线程的中断请求以及 响应 notify() 函数的通知来唤醒某个等待的线程。利用 synchronized 提供的 排它性 结合 wait() 和 notify() 函数提供的等待/通知机制,可以实现一个简单的排它锁( 其中 synchronized 保证任意时刻只有一个线程能获取锁,从而可以安全的通过更改锁的状态来表示 acquire ‘获取’锁与 release ’释放‘锁,而 wait() 和 notify() 方法配合用于管理获取锁失败的线程,使得当持有锁的线程释放锁时,等待锁的线程能够及时被通知)。使用自定义锁代替计数器中累加操作的 synchronized 关键字,代码如下(仅仅修改计数器 Counter 类以及新增自定义锁类 MyLock):
/*
* 多个线程同时更新计数器(使用自定义锁同步多线程操作)
*/
public class Temp_1 {
public static void main(String[] args) {
// 连续模拟操作 10 次
for(int i = 0;i < 10;i++) {
update_counter_demo();
}
}
// 构造工作者线程,模拟多线程同时更新计数器
public static void update_counter_demo(){
Counter counter = new Counter(0);
int worker_thread_num = 10;
Thread[] workers = new Thread[worker_thread_num];
for(int j = 0;j < worker_thread_num;j++) {
workers[j] = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(100); // 先等待 100 毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}
for(int i = 0;i < 100;i++) {
counter.plusOne();
}
}
});
}
// 启动所有工作者线程
for(int i = 0;i < worker_thread_num;i++) {
workers[i].start();
}
// 等待线程结束
for(int i = 0;i < worker_thread_num;i++) {
try {
workers[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 打印计数器数值
System.out.println(counter);
}
/* 简单计数器 */
private static class Counter {
private int count;
private MyLock lock;
public Counter(int count) {
this.count = count;
this.lock = new MyLock();
}
/* 计数器加 1 */
public void plusOne() {
lock.lock(); // 获取锁:保护累加操作
try {
count++;
} finally {
lock.unLock(); // 释放锁
}
}
@Override
public String toString() {
return "[ count is " + count + " ]";
}
}
}
/* 简单锁实现(注意:这里并没有记录当前持有锁的线程对象,
* 所以不能阻止未持有锁的线程释放锁,仅仅为了实现简单,
* 而且如果使用方式正确,就不会有这种问题) */
class MyLock{
private int count = 0;
/* 获取锁 */
public void lock() {
synchronized(this) {
while(count != 0) {
try {
wait(); // 当前线程主动等待(期望被 notify() 或 interrupted())
} catch (InterruptedException e) {
e.printStackTrace();
}
}
count++;
}
}
/* 释放锁 */
public void unLock() {
synchronized (this) {
if(count > 0) {
count--;
notify(); // 通知(唤醒)在同一个对象监视器上等待的线程
}
}
}
}
执行程序,运行结果正确。自定义锁 MyLock 中,lock() 方法执行加锁操作,它在一个 synchronized 代码块中检查锁的状态,如果锁的状态不为 0 则表示‘已加锁’,执行等待操作,否则通过更改锁的状态,从而获取锁。释放锁的操作也类似, 在 synchronized 提供的排它性保障下,通过更改锁的状态来表达‘加锁’与‘解锁’ 。当然,这个自定义锁的明显缺陷有:1 没有记录是那个线程拥有锁,从而不能阻止未执行加锁操作的线程释放锁。
- 同步的概念。与 ‘同步’ 相对的是 ‘异步’,它们都是在多线程编程中会使用到的概念。先说 ‘异步’,线程 A 委托线程 B 执行某个操作,但它不必等待线程 B 执行完毕,而是继续做其它的事情。而 ‘同步’ 并不意味着两个或多个线程同时执行,‘同步’ 是指多个线程 互相配合,相互协作 以完成工作,线程之间有沟通,有交流,并不是一个个‘孤岛’。上述在多线程中使用 synchronized 关键字和使用利用 ‘等待 / 通知’机制实现的自定义排它锁来完成计数器累加操作都是同步的例子。