1 进入并行世界
1.1 相关概念
1.1.1 同步和异步
同步和异步通常用来形容一次方法调用。
同步(Synchronous):同步方法调用一旦开始, 调用者必须 等到方法调用返回后, 才能继续后续的行为。
异步(Asynchronous):异步方法调用更像一个消息传递, 一旦开始, 方法调用就会立即返回, 调用者就可以继续后续的操作。 而异步方法通常会在另外一个线程中执行。整个过程, 不会阻碍调用者的工作。
1.1.2 并发和并行
并发和并行都可以表示两个或者多个任务一起执 行,但是侧重点有所不同。
并发(Concurrency):并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。
并行(Parallelism):并行是真正意义上的同时执行。
如果系统内只有一个 CPU , 而使用多进程或者多线程任务,那么真实环境中这些任务不可能是真实并行的,因为一个 CPU 一次只能执行一条指令, 在这种情况下多进程或者多线程就是并发的, 而不是并行的(操作系统会不停地切换多个任务)。
1.1.3 临界区
临界区用来表示一种公共资源或者说共享数据,可以被多个线程使用。但是每一次,只能有一个线程使用它,一旦临界区资源被占用,其他线程要想使用这个资源就必须等待。
1.1.4 阻塞和非阻塞
阻塞(Blocking):如果一个线程占用了临界区资源, 那么其他所有需要这个资源的线程就必须在这个临界区中等待。等待会导致线程挂起,这种情况就是阻塞。
非阻塞(Non-Blocking):非阻塞的意思强调没有一个线程可以妨碍其他线程执行,所有的线程都会尝试不断前向执行。
1.1.5 死锁、饥饿和活锁
死锁、饥饿和活锁都属于多线程的活跃性问题。
死锁(Deadlock):指由于两个或者多个线程互相持有对方所需要的资源,且都不愿意释放资源,导致这些线程处于等待状态,无法前往执行。
饥饿(Starvation):指某一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执 行。比如它的线程优先级可能太低,而高优先级的线程不断抢占它需要的资源,导致低优先级线程无法工作。或者某一个线程一直占着关键资源不放,导致其他需要这个资源的线程无法正常执行。
活锁(Livelock):线程之间主动将资源释放给他人使用,那么就会导致资源不断地在线程间跳动,而没有一个线程可以同时拿到所有资源正常执行。
1.2 并发级别
由于临界区的存 在,多线程之间的并发必须受到控制。根据控制并发的策略,我们可以把并发的级别分为阻塞、无饥饿、无障碍、无锁、无等待几种。
1.2.1 阻塞
阻塞的控制方式属于悲观策略,也就是说,系统认为两个线程之间很有可能发生不幸的冲突,因此以保护共享数据为第一优先级。
一个线程是阻塞的,那么在其他线程释放资源之前,当前线程无法继续执行。当我们使用 synchronized 关键字或者重入锁时,我们得到的就是阻塞的线程。
synchronized 关键字和重入锁都试图在执行后续代码前,得到临界区的锁,如果得不到,线程就会被挂起等待,直到占有了所需资源为止。
1.2.2 无饥饿(Starvation-Free)
如果线程之间是有优先级的,那么线程调度的时候总是会倾向于先满足高优先级的线程。也就说是,对于同一个资源的分配,是不公平的。
对于非公平锁,系统允许高优先级的线程插队。这样有可能导致低优先级线程产生饥饿。
如果是公平锁,按照先来后到的规则,那么饥饿就不会产生,所有的线程都有机会行。
1.2.3 无障碍(Obstruction-Free)
无障碍是一种最弱的非阻塞调度。两个线程如果无障碍地执行,那么不会因为临界区的问题导致一方被挂起。但是一旦出现冲突情况,就应该进行回滚。
非阻塞的调度是一种乐观的策,它认为多个线程之间很有可能不会发生冲突,或者说这种概率不大。 因此大家都应该无障碍地执行。
一种可行的无障碍实现可以依赖一个“一致性标记” 来实现。线程在操作之前,先读取并保存这个标记,在操作完成后,再次读取检查这个标记是否被更改过,如果两者是一致的,则说明资源访问没有冲突。如果不一致,则说明资源可能在操作过程中与其他写线程冲突,需要重试操作。而任何对资源有修改操作的线程,在修改数据前,都需要更新这个一致性标记,表示数据不再安全。
1.2.4 无锁(Lock-Free)
无锁的并行都是无障碍的。在无锁的情况下,所有的线程都能尝试对临界区进行访问, 但不同的是,无锁的并发保证必然有一个线程能够在有限步内完成操作离开临界区。
1.2.5 无等待(Wait-Free)
无锁只要求有一个线程可以在有限步内完成操作,而无等待则在无锁的基础求所有的线程都必须在有限步内完成,这样就不会引起饥饿问题。
如果限制这个步骤的上限,还可以进一步分解为有界无等待和线程数无关的无等待等几种,它们之间的区别只是对循环次数的限制不同。
一种典型的无等待结构就是 RCU (Read Copy Update ) 。它的基本思想是,对数据的读可以不加控制。因此,所有的读线程都是无等待的, 它们既不会被锁定等待也不会引起任何冲突。但在写数据的时候,需要先取得原始数据的副本,接着只修改副本数据(这就是为什么读可以不加控制),修改完成后,在合适的时机回写数据。
1.3 有关并行的两个重要定律
将串行程序改造为并发程序,一般来说可以提高程序的整体性能,但是究竟能提高多少,甚至说究竟是否真的可以提高。 目前, 主要有两个定律,对这个问题进行解答,一个是 Amdahl 定律,另外一个是 Gustafson 定律。
1.3.1 Amdahl 定律
Amdahl 定律定义了串行系统并行化后的加速比的计算公式和理论上限。
加速比 = 优化前系统耗时 / 优化后系统耗时
T<sub>n</sub> = T<sub>1</sub> * (F + 1/n * (1 - F))
加速比 = T<sub>1</sub>/T<sub>n</sub>
= 1/(F + 1/n * (1 - F))
其中:
n:处理器个数
T:时间
T<sub>1</sub>:优化前耗时。
T<sub>n</sub>:优化后耗时。
F:程序中只能串行执行的比例。
1.3.2 Gustafson 定律
a:串行时间
b:并行时间
n:处理器个数
执行时间:a + b
总执行时间:a + nb
加速比:(a + nb)/(a + b)
串行比例:F = a/(a + b)
加速比:S(n) = (a + nb)/(a + b)
= a/(a + b) + nb/(a + b)
= F + n*((a+b-a)/a+b)
= F + n*(1 - a/(a + b))
= F + n*(1 - F)
= n - F(n - 1)
1.4 Java:JMM
Java 的内存模型 ( JMM ) 的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的。
1.4.1 原子性(Atomicity)
原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
比如,对千一个静态全局变量 int i,两个线程同时对它赋值,线程A 给它赋值 1, 线程B 给它赋值为 -1。那么不管这两个线程以何种方式、何种步调工作,i 的值要么是 1, 要么是 -1。 线程 A 和线程 B 之间是没有干扰的。这就是原子性的一个特点,不可被中断。
但是在 32 位系统中,long 型数据的读写不是原子性的(因为 long 型数据有 64 位)。 如果两个线程同时对 long 型数据进行写入(或者读取),则对线程之间的结果是有干扰的。
1.4.2 可见性(Visibility)
可见性是指当一个线程修改了某一个共享变量的值时, 其他线程是否能够立即知道这个修改。这个问题存在于并行程序中。 如果一个线程修改了某 一个全局变量, 那么其他线程未必可以马上知道这个改动。
如果在 CPU1 和 CPU2 上各运行了一个线程, 它们共享变量 t, 由千编译器优化或者硬件优化的缘故, 在CPU1上的线程将变量 t 进行了优化,将其缓存在 cache 中或者寄存器里。在这种情况下,如果在 CPU2 上的某个线程修改了变量 t 的实际值,那么 CPU1上的线程可能无法意识到这个改动,依然会读取 cache 中或者寄存器里的数据。
1.4.3 有序性(Ordering)
在并发时,程序的执行可能就会出现乱序。给人的直观感觉就是: 写在前面的代码,会在后面执行。
有序性问题的原因是程序在执行时,可能会进行指令重排(流水线技术和乱序执行),重排后的指令与原指令的顺序未必一致。
1.4.4 指令重排
Java 虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并非所有的指令都可以随便改变执行位置,以下是一些基本原则:
- 程序顺序原则: 一个线程内保证语义的串行性。
- volatile 规则:volatile 变量的写先于读发生,这保证了 volatile 变量的可见性。
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock) 前。
- 传递性:A 先千 B,B 先于 C,那么 A 必然先于 C。
- 线程的 start() 方法先于它的每一个动作。
- 线程的所有操作先于线程的终结 Thread.join()。
- 线程的中断 interrupt() 先于被中断线程的代码。
- 对象的构造函数的执行、结束先于 finalize()方法。
这些原则都是为了保证 指令重排不会破坏原有的语义结构。
2 Java 并行基础
2.1 线程相关概念
进程 ( Process ) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在当代面向 线程设计的计算机结构中,进程是线程的容器。线程就是轻量级进程,是程序执行的最小单位。使用多线程而不是用多进程去进行并发程序的设计,是因为线程间的切换和调度的成本远远小于进程。
如图 1 是 Java 中一个线程的生命周期。
线程的所有状态都在 Thread 的 State 枚举中定义
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
2.2 线程基本操作
2.2.1 新建线程
// 新建线程 1
new Thread() {
@Override
public void run() {
System.out.println("hello world");
}
}.start();
// 新建线程2
new Thread(new Runnable() {
public void run() {
System.out.println("hello world");
}
}).start();
2.2.2 终止线程
一般来说,线程执行完毕就会结束,无须手工关闭。但是,凡事都有例外。 一些服务端的后台线程可能会常驻系统, 它们通常不会正常终结。 比如,它们的执行体本身就是一个大大的无穷循环,用于提供某些服务。
那么如何正常地关闭一个线程呢?
线程 Thread 提供了一个 stop () 方法。如果你使用 stop()方法,就可以立即将一个线程终止,但是该方法是一个被标注为废弃的方法。因为 stop()方法过千暴力,强行把执行到一半的线程终止,可能会引起一些数据不一致的问题。
Thread.stop()方法在结束线程时,会直接终止线程并立即释放这个线程所持有的锁, 而这些锁恰恰是用来维持对象一致性的。如果此时,写线程写入数据正写到一半并强行终止,那么对象就会被写坏 同时,由于锁已经被释放,另外一个等待该锁的读线程就顺理成章地读到了这个不一致的对象。这个过程如图 2 所示。
首先,对象 u 持有 ID 和 NAME 两个字段,假设当 ID 等于 NAME 时表示对象是一致的,写线程总是会将 ID 和 NAME 写成相同的值,并且在这里初始值都为 0。当写线程在写对象时,读线程由于无法获得锁,因此必须等待, 所 以读线程是看不见一个写了一半的对象的。当写线程写完 ID 后,很不幸地被 stop(),此时对象 u 的 ID 为 1 而 NAME 仍然为 0,处于不一致状态。而被终止的写线程简单地将锁释放,读线程争夺到锁后,读取数据,于是读到了 ID = l 而 NAME=O 的错误值。
代码模拟如下:
package com.bin.基础操作;
/**
* 使用 stop 停止线程不安全
*
* @author liyibin
* @date 2021-07-11
*/
public class ThreadStopUnSafe {
private static User u = new User();
public static void main(String[] args) throws InterruptedException {
// 启动读线程
new ReadObjectThread().start();
// 启动写线程
while (true) {
ChangeObjectThread t = new ChangeObjectThread();
t.start();
Thread.sleep(150);
t.stop();
}
}
/**
* 写线程
*/
public static class ChangeObjectThread extends Thread {
@Override
public void run() {
while (true) {
synchronized (u) {
int v = (int) (System.currentTimeMillis() / 1000);
u.setId(v);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
u.setName(String.valueOf(v));
}
// 让出 cpu
Thread.yield();
}
}
}
public static class ReadObjectThread extends Thread {
@Override
public void run() {
while (true) {
synchronized (u) {
if (u.getId() != Integer.parseInt(u.getName())) {
System.out.println(u.toString());
}
}
// 让出 cpu
Thread.yield();
}
}
}
private static class User {
private int id;
private String name;
private User() {
this.id = 0;
this.name = "0";
}
@Override
public String toString() {
return "[id=" + id + ", name=" + name + "]";
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}
// 运行结果
[id=1625974892, name=1625974891]
[id=1625974892, name=1625974891]
[id=1625974892, name=1625974891]
可以看到以上出现了错误的结果,那么如何正确的停止一个线程?
只需要由我们自行决定线程何时退出就可以了。仍然用本例说明, 只需要将 ChangeObjectThread 线程增加一个 stopThread()方法即可。
/**
* 写线程
*/
public static class ChangeObjectThread extends Thread {
volatile boolean stopMe = false;
public void stopThread() {
stopMe = true;
}
@Override
public void run() {
while (true) {
if (stopMe) {
System.out.println("stop thread");
break;
}
synchronized (u) {
int v = (int) (System.currentTimeMillis() / 1000);
u.setId(v);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
u.setName(String.valueOf(v));
}
// 让出 cpu
Thread.yield();
}
}
}
2.2.3 线程中断
在 Java 中,线程中断是一种重要的线程协作机制。 从表面上理解,中断就是让目标线程停止执行的意思,但是实际上线程中断并不会使线程立即退出,而是给线程发送一个通知,告知目标线程,有人希望你退出啦!至于目标线程接到通知后如何处理,则完全由目标线程自行决定。
以下三个方法与线程中断有关:
// 中断线程
public void interrupt(){}
// 是否被中断
public boolean isInterrupted(){}
// 是否被中断,并清除当前中断状态
private native boolean isInterrupted(boolean ClearInterrupted);
- interrupt:实例方法,通知目标线程中断,也就是设置中断标志位。中断标志位表示当前线程已经被中断了。必须手动处理中断逻辑,不会自动中断。
- isInterrupted:实例方法,它判断当前线程是否被中断(通过检查中断标志位)。
- isInterrupted(boolean ClearInterrupted):判断当前线程的中断状态, 但同时会清除当前线程的中断标志位状态。
// 让当前线程休眠若干时间
public static native void sleep(long millis) throws InterruptedException;
Thread.sleep()方法会让当前线程休眠若干时间,它会抛出一个 Interru ptedException 中断异常。InterruptedException 不是运行时异常,也就是说程序必须捕获并且处理它,当线程在 sleep()休眠时,如果被中断,这个异常就会产生。
package com.bin.基础操作;
/**
* @author liyibin
* @date 2021-07-13
*/
public class ThreadInterrupt {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread() {
@Override
public void run() {
while (true) {
// 是否被中断
if (Thread.currentThread().isInterrupted()) {
System.out.println("Interrupted");
break;
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
// 线程休眠中被中断
System.out.println("Interrupted When Sleep");
// 再次设置中断状态位
Thread.currentThread().interrupt();
}
Thread.yield();
}
}
};
t1.start();
Thread.sleep(2000);
// 设置中断标志位
t1.interrupt();
}
}
如果线程在 try 中被中断,则程序会抛出异常,并进入 catch 子句部分处理,由于已经捕获了中断,我们可以立即退出线程。但可能我们还必须进行后续的处理来保证数据的一致性和完整性,因此执行了 Thread.interrupt() 方法再次中断自己,置上中断标记位。只有这么做,在 Thread.currentThread().isInterrupted() 的中断检查中,才能发现当前线程已经被中断了。
注意: Thread .sleep ()方法由于中断而抛出异常,此 时,它会清除中断标记,如果不加处理,那么在下一次循环开始时,就无法捕获这个中断,故在异常处理中,再次设置中断标记位。
2.2.4 等待(wait)和通知(notify)
为了支持多线程之间的协作,Java 提供了两个非常重要的方法: 等待 wait() 方法和通知 notify()方法。这两个方法并不是在 Thread 类中的,而是在 Object 类。这也意味着任何对象都可以调用这两个方法。
public final native void notify();
public final native void notifyAll();
public final void wait() throws InterruptedException{}
public final native void wait(long var1) throws InterruptedException;
当在一个对象实例上调用 wait() 方法后,当前线程就会在这个对象上等待。比如, 在线程 A 中,调用了obj.wait()方法,那么线程 A 就会停止继续执行,转为等待状态。直到其他线程调用了 obj.notify()方法为止。如图 3 展示了两者的工作过程。
如果一个线程调用了 object.wait()方法,那么它就会进入 object 对象的等待队列。这个等待队列中,可能会有多个线程,因为系统运行多个线程同时等待某一个对象。当 object.notify() 方法被调用时,它就会从这个等待队列中随机选择一个线程。
除 notify() 方法外,Object 对象还有一个类似的 notifyAll()方法, 它和 notify()方法的功能基本一致,不同的是,它会唤醒在这个等待队列中所有等待的线程,而不是随机选择一个。
注意:notify() 和 wait() 方法必须包含在对应的synchronzied 语句中,都需要首先获得目标对象的一个监视器。
图 4 展示了 wait() 方法和 notify()方法的工作流程细节。
wait() 方法和 notify()的使用案例。
package com.bin.基础操作;
/**
* @author liyibin
* @date 2021-07-13
*/
public class SimpleWaitNotify {
static final Object obj = new Object();
public static void main(String[] args) {
Thread t1 = new T1();
Thread t2 = new T2();
t1.start();
t2.start();
}
private static class T1 extends Thread {
@Override
public void run() {
// 申请获取 object 的对象锁
synchronized (obj) {
System.out.println(System.currentTimeMillis() + ":T1 start");
try {
System.out.println(System.currentTimeMillis() + ":T1 wait for object");
// T1 进行等待并释放锁
obj.wait();
} catch (InterruptedException e) {
// 等待中线程被中断
e.printStackTrace();
}
System.out.println(System.currentTimeMillis() + ":T1 end");
}
}
}
private static class T2 extends Thread {
@Override
public void run() {
// 申请获取 object 的对象锁
synchronized (obj) {
System.out.println(System.currentTimeMillis() + ":T2 start");
// 进行通知
obj.notify();
System.out.println(System.currentTimeMillis() + ":T2 end");
// 休眠
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
// 休眠中线程被中断
e.printStackTrace();
}
}
}
}
}
// 运行结果
1626184401127:T1 start
1626184401127:T1 wait for object
1626184401128:T2 start
1626184401128:T2 end
1626184403128:T1 end
上述代码中,开启了 T1 和 T2 两个线程。 T1 执行了 objec t.wait ()方法。注意,执行 wai t()方法前, T1 先申请 object 的对象锁。 因此,在执行 object.wait()时, 它是持有 object 的对象锁的。wait()方法执行后,T1会进行等待,并释放 object 的对象锁。T2 在执行 notify()方法之前也会先获得 object 的对象锁。这里让 T2 休眠 2 秒,为了更明显地说明, T1 在得到 notify() 方法通知后,还是会先尝试重新获得 object 的对象锁。
在 T2 通知 T1 继续执行后,T1 并不能立即继续执行,而是要等待 T2 释放 object 的锁,并重新成功获得锁后, 才能继续执行。
注意: Object.wait()方法和 Thread.slee() 方法都可以让线程等待若干时间。除 wait() 方法可以被唤醒外,另 外一个主要区别就是 wait() 方法会释放目标对象的锁,而 Thread.sleep() 方法不会释放任何资源。
2.2.5 挂起(suspend)和继续执行(resume)
已被标注为废弃方法,并不推荐使用。
suspend()方法在导致线程暂停的同时,并不会释放任何锁资源。此时, 其他任何线程想要访问被它占用的锁时, 都会被牵连,导致无法正常继续运行。直到对应的线程上进行了 resume()方法操作,被挂起的线程才能继续,从而其他所有阻塞在相关锁上的线程也可以继续执行。
如果 resume() 方法操作意外地在 suspend()方法前就执行了, 那么被挂起的线程可能很难有机会被继续执行。 并且,它所占用的锁不会被释放,因此可能会导致整个系统工作不正常(如图 5 所示)。而且, 对于被挂起的线程,从它的线程状态上看,还是 Runnable ,这会严重影响我们对系统当前状态的判断。
下述代码展示了 suspend 存在的问题
public class ThreadSuspend1 {
private static Object u = new Object();
private static ChangeObjectThread t1 = new ChangeObjectThread("t1");
private static ChangeObjectThread t2 = new ChangeObjectThread("t2");
public static void main(String[] args) throws InterruptedException {
t1.start();
Thread.sleep(100);
t2.start();
// 继续执行
t1.resume();
t2.resume();
// 等待子线程执行完毕
t1.join();
t2.join();
}
private static class ChangeObjectThread extends Thread {
public ChangeObjectThread(String name) {
super(name);
}
@Override
public void run() {
synchronized (u) {
System.out.println("in " + getName());
// 挂起
Thread.currentThread().suspend();
}
}
}
}
上述代码,开启 t l 和 t2 两个线程,通过对象锁 u 实现对临界区的访问。在主函数先后对两个线程 resume 操作,目的是让它们得以继续执行。
执行上述代码后,我们可能会得到以下输 出:
in t1
in t2
这表明两个线程先后进入了临 界区,但是程序不会退出,而是会挂起。使用 jstack 命令打印系统的线程信息:
D:\environment\Java\jdk1.8.0_201\bin>jps
11472 Launcher
13528 Jps
4280 ThreadSuspend1
8508
D:\environment\Java\jdk1.8.0_201\bin>jstack 4280
2021-07-20 20:27:23
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.181-b13 mixed mode):
"t2" #12 prio=5 os_prio=0 tid=0x00000000191e9800 nid=0x714 runnable [0x0000000019e9f000]
java.lang.Thread.State: RUNNABLE
at java.lang.Thread.suspend0(Native Method)
at java.lang.Thread.suspend(Thread.java:1032)
at com.bin.基础操作.ThreadSuspend1$ChangeObjectThread.run(ThreadSuspend1.java:39)
- locked <0x00000000d648a908> (a java.lang.Object)
可以看到线程 t2 其实是被挂起的,但是它的线程状态是 RUNNABLE,这很有可能使我们误判当前系统的状态。虽然主函数中已经调用了 resume()方法, 但是由于时间先后顺序的缘故,那个 resume 并没有生效!这就导致了线程 t2 被永远挂起,并且永远占用了对象 u 的锁。
如果需要一个比较可靠的 suspend()方法,那么应该怎么办呢? 可以利用 wait() 方法和 notify()方法, 在应用层面实现 suspend() 方法和 resume() 方法功能。代码如下所示:
public class ThreadSuspend2 {
private static Object u = new Object();
public static void main(String[] args) throws InterruptedException {
ChangeObjectThread t1 = new ChangeObjectThread();
ReadObjectThread t2 = new ReadObjectThread();
t1.start();
t2.start();
Thread.sleep(1000);
// 挂起
t1.suspendMe();
System.out.println("suspend t1 2 sec");
Thread.sleep(2000);
System.out.println("resume t1");
t1.resumeMe();
}
private static class ChangeObjectThread extends Thread {
volatile boolean suspendMe = false;
public void suspendMe() {
suspendMe = true;
}
public void resumeMe() {
suspendMe = false;
synchronized (this) {
notify();
}
}
@Override
public void run() {
synchronized (this) {
while (suspendMe) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
synchronized (u) {
System.out.println("in ChangeObjectThread");
}
Thread.yield();
}
}
private static class ReadObjectThread extends Thread {
@Override
public void run() {
synchronized (u) {
System.out.println("in ReadObjectThread");
}
Thread.yield();
}
}
}
代码中用标记变量 suspendMe 表示当前线程是否被挂起。同时,增加 suspendMe()和 resumeMe()两个方法, 分别用于挂起线程和继续执行线程。
t1 线程会先检查自己是否被挂起,如果是,则执行 wait() 方法进行等待。否则,则进行正常的处理。当线程继续执行时,resumeMe() 方法被调用线程 t1 得到一个继续执行的 notify()方法通知,并且清除了挂起标记,从而得以正常执行。
2…2.6 等待线程结束(join)和谦让(yield)
很多时候,一个线程的输入可能非常依赖千另外一个或者多个线程的输出,此 时,这个线程就需要等待依赖线程执行完毕,才能继续执行。JDK 提供了 join() 操作来实现这个功能。方法签名如下:
// 无限等待,它会一直阻塞当前线程直到目标线程执行完毕。
public final void join() throws InterruptedException {
join(0);
}
// 给出了一个最大等待时间,如果超过给定时间目标线程还在执行,当前线程也会继续往下执行。
public final synchronized void join(long millis) throws InterruptedException
一个 join 代码示例如下:
public class ThreadJoin {
public static volatile int i = 0;
public static void main(String[] args) throws InterruptedException {
AddThread at = new AddThread();
at.start();
at.join();
System.out.println(i);
}
private static class AddThread extends Thread {
@Override
public void run() {
for (i = 0; i < 100000000; i++);
}
}
}
主 函数中, 如果不使用 join()方法等待 AddThread , 那么得到的 i 很可能是 0 或者一个非常小的数字。因为AddTbread 还没开始执行,i 的值就已经被输出了。但在使用 join() 方法后, 表 示主线程愿意等待 AddThread 执行完毕,跟着 AddThread 一起往前走,故在 join() 方法返回时, AddThread 已经执行完成,因此 i 总是 10000000。
JDK 中 join() 的核心实现就是通过 wait() 方法.
while (isAlive()) {
wait(0);
}
可以看 到,它让调用线程在当前线程对象上进行等待。当线程执行完成后,被等待的线程会在退出前调用 notifyAll()方法通知所有的等待线程继续执行。
Thread.yield() 方法签名如下:
public static native void yield();
这是一个静态方法,一旦执行,它会使当前线程让出 CPU。 但要注意,让出 C PU 并不表示当前线程不执行了。当前线程在让出 CPU 后, 还会进行 CPU 资源的争夺,但是是否能够再次被分配到就不一定了。 如果一个线程不那么重要,或者优先级非常低,可以在适当的时候调用 Thread.yield() 方法,给予其他重要线程更多的工作机会。
2.3 volitile 与 Java 内存模型(JMM)
Java 内存模型(JMM)都是围绕着原子性、有序性和可见性展开的。为了在适当的场合,确保线程间的有序性、可见性和原子性。Java 使用了一些特殊的操作或者关键字来声明、告诉虚拟机,不能随意变动优化目标指令。 关键字volatile 就是其中之一。
当你用关键字 volatile 声明一个变量时,就等于告诉了虚拟机, 这个变量极有可能会被某些程序或者线程修改。虚拟机必须采用一些特殊的手段, 保证这个变量的可见性等特点。
比如, 根据编译器的优化规则,如果不使用关键字 volatile 声明变量,那么这个变量被修改后,其他线程可能并不会被通知到,甚至在别的线程中,看到变量的修改顺序都会是反的。一旦使用关键字 volatile,虚拟机就会特别小心地处理这种情况。
2.4 线程组
在一个系统中,如果线程数量很多,而且功能分配比较明确,就可以将相同功能的线程放置在同一个线程组里。使用如以下例子:
public class ThreadGroupDemo implements Runnable {
public static void main(String[] args) {
ThreadGroup tg = new ThreadGroup("PrintGroup");
Thread t1 = new Thread(tg, new ThreadGroupDemo(), "T1");
Thread t2 = new Thread(tg, new ThreadGroupDemo(), "T2");
t1.start();
t2.start();
System.out.println(tg.activeCount());
// 打印这个线程组中所有的线程信息
tg.list();
}
public void run() {
String groupAndName = Thread.currentThread().getThreadGroup().getName()
+ "_" + Thread.currentThread().getName();
while (true) {
System.out.println("I am " + groupAndName);
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 执行结果
2
java.lang.ThreadGroup[name=PrintGroup,maxpri=10]
Thread[T1,5,PrintGroup]
Thread[T2,5,PrintGroup]
I am PrintGroup_T1
I am PrintGroup_T2
I am PrintGroup_T1
I am PrintGroup_T2
I am PrintGroup_T2
I am PrintGroup_T1
2.5 守护线程(Daemon)
守护线程是一种特殊的线程,在后台默默地完成一些系统性的服务, 比如垃圾回收线程、JIT 线程就可以理解为守护线程。与之相对应的是用户线程,用户线程是系统的工作线程,它会完成这个程序应该要完成的业务操作。如果用户线程全部结束,则意味着这个程序实际上无事可做了。守护线程要守护的对象已经不存在了,那么整个应用程序就应该结束。因此,当一个 Java 应用内只有守护线程时,Java 虚拟机就会自然退出。
守护线程的使用:
public class ThreadDaemon {
public static void main(String[] args) {
Thread t = new DeamonThread();
t.setDaemon(true);
t.start();
Thread.sleep(2000);
}
private static class DeamonThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println("I am alive");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
// 执行结果
I am alive
I am alive
注意:设置守护线程必须在线程 start () 之前设置,否则你会得到一个类似以下的异常,告诉你守护线程设置失败。但是你的程序和线程依然可以正常 执行,只是被当作用户线程而已。
Exception in thread "main" java.lang.IllegalThreadStateException
at java.lang.Thread.setDaemon(Thread.java:1359)
at com.bin.基础操作.ThreadDaemon.main(ThreadDaemon.java:14)
在这个例子中,由于 t 被设置为守护线程,系统中只有主线程 main 为用户线程,因此在 main 线程休眠 2 秒后退出时,整个程序也随之结束。但如果不把线程 t 设置为守护线程,那么 main 线程结束后,t 线程还会不停地打印,永远不会结束。
2.6 线程优先级
Java 中的线程可以有 自己的优先级。优先级高的线程在竞争资源时会更有优势,更可能抢占资源。当然,这只是一个概率问题。如果运气不好,那么高优先级线程可能也会抢占失败。一个低优先级的线程可能会一直抢占不到资源, 从而始终无法运行, 而产生饥饿。因此,在要求严格的场合,需要自己在应用层解决线程调度问题。
在 Java 中,使用 1 到 10 表示线程优先级。一般可以使用内置的三个静态标量表示:
public final static int MIN_PRIORITY = 1;
public final static int NORM_PRIORITY = 5;
public final static int MAX_PRIORITY = 10;
数字越大则优先级越高, 但有效范围在 1 到 10 之间。下面的代码展示了优先级的作用。高优先级的线程倾向于更快地完成。
public class ThreadPriority {
public static void main(String[] args) {
Thread high = new HighPriority();
Thread low = new LowPriority();
high.setPriority(Thread.MAX_PRIORITY);
low.setPriority(Thread.MIN_PRIORITY);
low.start();
high.start();
}
private static class HighPriority extends Thread {
static int count = 0;
@Override
public void run() {
while (true) {
synchronized (ThreadPriority.class) {
count++;
if (count > 100000000) {
System.out.println("HighPriority is complete");
break;
}
}
}
}
}
private static class LowPriority extends Thread {
static int count = 0;
@Override
public void run() {
while (true) {
synchronized (ThreadPriority.class) {
count++;
if (count > 100000000) {
System.out.println("LowPriority is complete");
break;
}
}
}
}
}
}
上述代码定义了两个线程,分别把 HightPriority 设置为高优先级, LowPriority 为低优先级。让它们完成相同的工作,也就是把 count 从 0 加到 10000000。完成后,打印信息给一个提示,这样我们就知道谁先完成了。注意,在对 count 累加前,我们使用关键字synchronized 产生了一次资源竞争,目的是使得 优先级的差异表现得更为明显。高优先级的线程在大部分情况下, 都会首先完成任务。
2.7 synchronized
关键字 synchronized 的作用是实现线程间的同步。它的工作是对同步的代码加锁,使得每一次,只能有一个线程进入同步块。
关键字 synchronized 的作用是实现线程间的同步。它的工作是对同步的代码加锁,使得每一次,只能有一个线程进入同步块:
- 指定加锁对象: 对给定对象加锁, 进入同步代码前要获得给定对象的锁。
- 直接作用于实例方法: 相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
- 直接作用千静态方法: 相当于对当前类加锁, 进入同步代码前要获得当前类的锁。
2.8 程序中的幽灵:隐蔽的错误
2.8.1 并发下的 ArrayList
ArrayList 是一个线程不安全的容器。如果在多线程中使用 ArrayList,可能会导致程序出错。 参考以下代码。
public class ArrayListMultiDemo {
static List<Integer> list = new ArrayList<Integer>(10);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new AddThread());
Thread t2 = new Thread(new AddThread());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(list.size());
}
private static class AddThread implements Runnable {
public void run() {
for (int i = 0; i < 1000000; i++) {
list.add(i);
}
}
}
}
上述代码中,t l 和 t2 两个线程同时向一个 ArrayList 中添加容器。它们各添加 100 万个元素 因此我们期望最后可以有 200 万个元素在 ArrayList 中。但如果执行这段代码,则可能得到三种结果。
第一种情况,程序正常结束,ArrayList 的最终大小确实 200 万。这说明即使并行程序有问题, 也未必会每次都表现出来。
第二种情况,程序抛出异常。
Exception in thread "Thread-1" java.lang.ArrayIndexOutOfBoundsException: 163
at java.util.ArrayList.add(ArrayList.java:463)
at com.bin.基础操作.ArrayListMultiDemo$AddThread.run(ArrayListMultiDemo.java:28)
at java.lang.Thread.run(Thread.java:748)
这是因为 ArrayList 在扩容过程 中, 内部一致性被破 坏, 但由千没有锁的保护, 另外一个线程访问到了不一致 的内部状态, 导致出现越界问题。
第三种情况,出现 了一个非常隐蔽的错误, 比如打印如下值作 为 ArrayList 的大小。
1425682
这是由于多线程访问冲突,使得保存容器大小的变量被多线程不正常 的访问,同时两个线程也对 ArrayList 中的同一个位置进行赋值导致的。
2.8.2 并发下诡异的 HashMap
HashMap 同样不是线程安全的。当你使用多线程访问 HashMap 时, 也可能会遇到意想不到的错误。如以下代码。
public class HashMapMultiDemo {
static Map<String, String> map = new HashMap<String, String>();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new AddThread(0));
Thread t2 = new Thread(new AddThread(1));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(map.size());
}
private static class AddThread implements Runnable {
int start = 0;
public AddThread(int start) {
this.start = start;
}
public void run() {
for (int i = start; i < 1000000; i += 2) {
map.put(Integer.toString(i), Integer.toBinaryString(i));
}
}
}
}
上述代码使用 t1 和 t2 两个线程同时对 HashMap 进行 put()方法操作。 如果一切正常,则得到的 m ap .s ize ()方法就是 100000。 但实际上,你可能会得到以下三种情况(注意, 这里使用 JDK 7 进行试验)。
第一, 程序正常结束,并且结果也是符合预期的,HashMap 的大小为 100000。
第二, 程序正常结束,但结果不符合预期,而是一个小于 100000 的数字,比如 98868。
第三, 程序永远无法结束。
for (Entry<K, V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
可以看到,当前这两个线程正在遍历 HashMap 的内部数据。当前所处循环乍看之下是一个迭代遍历,就如同遍历一个链表一样。但在此时此刻, 由于多线程的冲突, 这个链表的结构已经遭到了破坏, 链表成环了!当链表成环时, 上述的迭代就等同于一个死循环。
这个死循环的问题在 JDK 8 中已经不存在了。由于 JDK 8 对 HashMap 的内部实现做了大规模的调整,因此规避了这个问题。
2.8.3 错误的加锁
在进行多线程同步时,加锁是保证线程安全的重要手段之一。但加锁也必须是合理的,如以下代码:
public class BadLockOnInteger implements Runnable {
public static Integer i = 0;
static BadLockOnInteger instance = new BadLockOnInteger();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
public void run() {
for (int j = 0; j < 10000000; j++) {
synchronized (i) {
i++;
}
}
}
}
// 执行结果
10680667
上述代码的为了保证计数器 i 的正确性,每次对 i 自增前,都先获得 i 的锁,以此保证 i 是线程安全的。从逻辑上看, 这似乎并没有什么不对。如果一切正常,那么这段代码应该返回 20000000(每个线程各累加 10000000 次)。
但结果却得到了一个比20000000 小很多的数字。
要解释这个问题,得从 Integer 说起。 在 Java 中, Integer 属 千不变对象,即对象一旦被创建,就不可能被修改。 那如果你需要另一个值需要新建一个 Integer 对象并让它表示即可。
i++ 在真实执行时变成了:
i = Integer.valueOf (i.intValue() + 1) ;
进一步查看 Integer.valueOf(i) 方法
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
该方法实际上是一个工厂方法,它会倾向于返回一个代表指定数值的 Integer 对象实例。因此,i++ 的本质是创建一个新的 Integer 对象,并将它的引用赋值给 i。
因此在多个线程间,并不一定能够看到同一个 i 对象(因为 i 对象一直在变),因此,两个线程每次加锁可能都加在了不同的对象实例上,从而导致对临界区代码控制出现问题。
修正这个问题, 只要将下面的代码:
synchronized(1) {
改为
synchronized(instance) {
即可。