目录:
一、多进程与多线程的概念
多进程(多任务):操作系统的一种能力,可以让系统看起来能够在同一时刻运行多个程序。实际上,操作系统只是为每一个进程分配CPU时间片,多个进程交替占用CPU,由于时间片非常非常短,因此看起来像是在同时运行,实际上在单个CPU上还是串行的。
多线程:一个进程可以分成多个线程(子任务),这些线程可以同时在多个CPU上运行,或者一个CPU依次运行多个线程,这样能大大提高本程序的运行效率。
区别:
- 线程更轻量、更易转换
- 线程通讯更高效
- 进程拥有自己完整的一套变量,而线程共享数据
二、线程的状态
- New(新建)
- Runnable(可运行):可运行状态只是相对其他状态而言的,一个可运行状态的线程既可能正在运行,也可能永远未运行。
- Blocked(阻塞)& Waiting(等待):线程进入这两种非活动状态的原因有两点:a)未获得内部的对象锁。b)线程因使用wait方法、等待Condition等进入了等待状态。
- Timed waiting(计时等待):线程可以调用一些带有超时参数的方法进入计时等待状态,一旦接收到适当的通知或者达到规定时间,线程又会被重新激活。
- Terminated(终止):线程的终止只有两种情况:a)run方法正常退出,线程自然终止。b)因为出现了一个没有捕获的异常终止了run方法,线程意外终止。(这里不考虑已经被废弃的方法。)
三、用Thread & Runnable实现多线程
1、示例
假定你创建了一个Bank类,里面存储了账户数目以及每个账户的初始金额,并实现了一个transfer方法,将一个账户的金额转移到另一个账户:
import java.util.*;
//实现银行类
public class Bank
{
private final double[] accounts;
public Bank(int n, double initialBalance)
{
accounts = new double[n];
Arrays.fill(accounts, initialBalance);
}
public void transfer(int from, int to, double amount)
{
if(accounts[from] < amount) return;
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" %10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
}
public double getTotalBalance()
{
double sum = 0;
for(double a : accounts)
{
sum += a;
}
return sum;
}
}
现在,账户0需要转十次钱给账户1,账户2也需要同时转十次钱给账户3,每次转移若干金额。你该怎样用计算机来完成这个过程?
首先,我们需要定义线程:继承Thread类 / 实现Runnable接口。无论使用哪一种方式,都需要实现run()方法:
public interface Runnable //Runnable接口
{
void run();
}
class MyThread1 extends Thread//使用继承来定义线程
{
public static final int DELAY = 10;
public static fianl int STEPS = 100;
public static fianl double MAX_AMOUNT = 1000;
public void run()
{
try
{
for(int i = 0; i < STEPS; i++)
{
double amount = MAX_AMOUNT * Math.random();
bank.transfer(0, 1, amount);
Thread.sleep((int)(DELAY*Math.random());
}
}
catch(InterruptedException e){};
}
}
class MyThread2 implements Runnable//使用Runnable接口来定义线程
{
public static final int DELAY = 10;
public static final int STEPS = 100;
public static final double MAX_AMOUNT = 1000;
public void run()
{
try
{
for(int i = 0; i < STEPS; i++)
{
double amount = MAX_AMOUNT * Math.random();
bank.transfer(1, 2, amount);
Thread.sleep((int)(DELAY*Math.random());
}
}
catch(InterruptedException e){};
}
}
接着,我们需要构造一个Thread对象:
Thread t1 = new MyThread1();//使用继承的情况
Thread t2 = new Thread(new MyThread2());//使用接口的情况
最后,我们启动线程:
t1.start();
t2.start();
完整代码:
import java.util.*;
public class ThreadTest1 {
public static void main(String[] args)
{
var bank = new Bank(4, 1000);
Thread t1 = new MyThread1(bank);
Thread t2 = new Thread(new MyThread2(bank));
t1.start();
t2.start();
}
}
class MyThread1 extends Thread//使用继承来定义线程
{
public static final int DELAY = 10;
public static final int STEPS = 100;
public static final double MAX_AMOUNT = 1000;
private Bank bank;
public MyThread1(Bank bank)
{
this.bank = bank;
}
public void run()
{
try
{
for(int i = 0; i < STEPS; i++)
{
double amount = MAX_AMOUNT * Math.random();
bank.transfer(0, 1, amount);
Thread.sleep((int)(DELAY*Math.random()));
}
}
catch(InterruptedException e){};
}
}
class MyThread2 implements Runnable//使用Runnable接口来定义线程
{
public static final int DELAY = 10;
public static final int STEPS = 100;
public static final double MAX_AMOUNT = 1000;
private Bank bank;
public MyThread2(Bank bank)
{
this.bank = bank;
}
public void run()
{
try
{
for(int i = 0; i < STEPS; i++)
{
double amount = MAX_AMOUNT * Math.random();
bank.transfer(1, 2, amount);
Thread.sleep((int)(DELAY*Math.random()));
}
}
catch(InterruptedException e){};
}
}
class Bank
{
private final double[] accounts;
public Bank(int n, double initialBalance)
{
accounts = new double[n];
Arrays.fill(accounts, initialBalance);
}
public void transfer(int from, int to, double amount)
{
if(accounts[from] < amount) return;
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" %10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
}
public double getTotalBalance()
{
double sum = 0;
for(double a : accounts)
{
sum += a;
}
return sum;
}
}
输出结果:
可以看见,
线程0与线程1的执行顺序是随机的,这能充分说明,二者在并发运行。
请注意:
- 尽管使用继承方式生成的对象启动更为简洁,但一般不使用这种方式,因为Java不支持多重继承,不要占用继承名额。
- 整个程序中,我们都没有显式调用任何run方法,这是因为,当我们在调用start方法时,它会自动调用run方法。
- 关于start方法,一旦调用立刻返回,因此才有了并发运行的情况。
- 不用轻易显式调用run方法!直接调用run方法只会在一个线程中执行这个任务,并不会立刻返回,因此也不会并发运行。
2、interrupt方法
上文提到,
线程的终止只有两种情况:a)run方法正常退出,线程自然终止。b)因为出现了一个没有捕获的异常终止了run方法,线程意外终止。(这里不考虑已经被废弃的方法。)
不过,对一个线程使用interrupt方法可以请求终止这个线程。
对于每一个线程,都有一个boolean标志,可以用这个标志来判断是否线程被中断。
while(!Thread.currentThread().isInterrupted() && ...)//检查线程是否被中断
请注意:
- 当线程处于阻塞或等待状态时(sleep / wait 等)被中断,将会抛出InterruptedException异常。(有些阻塞不能被中断)
- 没有任何规定要求被请求终止的线程应当终止。被请求终止的线程甚至可以完全忽略该请求。
- 如果一个run方法中每次工作迭代后都调用sleep方法(或者其他可中断的阻塞线程的方法),则不必要也没任何用处来使用isInterrupted检测是否被中断。取而代之的应该是捕获InterruptedException异常的语句。
try
{
...
while(...)
{
...
Thread.sleep(delay);
}
}
catch(InterruptedException e)
{
...
}
finally
{
...
}
- 如果程序抛出了InterruptedException异常,它的清楚状态将会被解除!因此,在catch块中,我们常常会重新设置中断状态,方便调用者检查;或者,你可以选择只声明该异常,捕获直接交由调用者处理。
//形式一:
...
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
...
//形式二
void mySubTask() throws InterruptedException
- run方法不允许抛出任何异常(即不允许使用throws),因为run方法是再调用start方法后jvm调用的,Java肯定不允许异常抛给jvm的,所以任何异常都应在逃逸至run方法前被捕获。
3、守护线程
可以使用setDaemon方法将一个线程设置成为守护线程。
Thread t3 = new Thread(new MyThread2());
t3.setDaemon(true);
守护线程的作用:
- 为其他线程提供服务
守护线程的特点:
- 普通线程的结束是run方法运行结束(此时main可能早已结束)
- 守护线程的结束是run方法运行结束,或者main函数结束。
四、竞态条件
介绍竞态条件之前,我们先来了解一下非原子性语句(如i++)的操作流程:
- 将主存中的数据读取到工作缓存(寄存器)中
- 在寄存器中执行相应的操作得到结果
- 将结果写回主存中
前面提到过,
多线程与多进程的最大区别在于,多线程能共享数据。
现在试想一下这样一个场景,如果一个线程正在执行一个非原子性操作(假定为i++操作),当它执行到2未执行3时,另一个线程也开始执行这个操作,由于前一个线程的结果并未更新到主存,该线程存入缓存中的仍为原来的i的值,最终,后一个线程的结果 i+1将覆盖前一个线程的结果,并更新主存的值为 i+1,而实际上你期望得到的结果是 i+2。
定义:在多线程应用中,两个或两个以上的线程需要存取同一个对象,并且每个线程分别调用了一个修改该对象的方法,导致线程间相互覆盖的情况被称为竞态条件。
下面举一个更为详细的例子,在上面的银行类中,我们让所有账户都随机给另一个账户进行转账,每次都执行getTotal函数计算Bank中的存款总额。稍后你会发现惊奇的事情:
package unsynch;
import java.util.*;
public class UnsynchBankTest {
public static final int NACCOUNTS = 100;
public static final int DELAY = 10;
public static final double INITIAL_BANLANCE = 1000;
public static final double MAX_AMOUNT = 1000;
public static void main(String[] args)
{
var bank = new Bank(NACCOUNTS, INITIAL_BANLANCE);
for(int i = 0; i<NACCOUNTS; i++) {
int fromAccount = i;
Runnable r = ()->{
try
{
while(true)
{
int toAccount = (int)(NACCOUNTS * Math.random());
double amount = MAX_AMOUNT * Math.random();
bank.transfer(fromAccount, toAccount, amount);
Thread.sleep((int)(DELAY * Math.random()));
}
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
};
var t = new Thread(r);
t.start();
}
}
}
输出结果中的一小部分:
你会发现,多个账户之间相互转账,存款总额竟然减少了!
原因:
transfer方法不是原子操作,在一个线程执行transfer操作时可能会被另一个线程打断,一个线程可能会覆盖了另一个线程所做的更新,使得总金额不再正确。
解决思路:
- 确保该方法一次只能由一个线程调用。
- 确保一个变量改变时,其他线程可以看见这个改变。
五、使用重入锁(ReentrantLock)对象实现同步
ReentrantLock类能够设置一个锁对象,一旦一个线程使用lock方法将锁对象锁定,其他任何线程都无法通过lock语句。它们会暂停直至锁对象解锁(unlock)。
public class Bank
{
private ReentrantLock bankLock = new ReentrantLock();//创建一个锁对象
...
public void transfer(int from, int to, int amount)
{
bankLock.lock();//上锁
try
{
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.printf(" %10.2f from %d to %d", amount, from, to);
accounts[to] += amount;
System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
}
finally
{
bankLock.unlock();//解锁
}
}
}
请注意:
- unlock 语句一定要放置在 finally 语句中,确保在临界区(lock-unlock之间)的代码能够释放锁,否则其他线程将永久堵塞。
- ReentrantLock锁是一把重入锁,线程可以反复获得已经拥有的锁。被锁保护的代码可以调用另一个使用了相同锁的方法(当然也可以调用使用不同锁的方法)。
- ReentrantLock对象可以调用一个getHoldCount方法来获取已经拥有的锁的数量。譬如:假定getTotal函数也使用了bankLock锁,当transfer方法调用getTotal方法时,HoldCount的值变为2,退出时又变为1。
六、与锁相关联的条件对象(条件变量)
现在,我们希望对transfer方法进行优化:如果账户余额小于转账金额,这个线程的锁应当被释放,并且重新进入等待状态,直至被唤醒,再次尝试转账操作。
具体操作如下:
public class Bank
{
private Condition sufficientFunds;//创建一个条件变量
...
public Bank()
{
...
sufficientFunds = bankLock.newCondition();//将条件变量与锁对象相关联
}
public void transfer(int from, int to, int amount)
{
bankLock.lock();//上锁
try
{
while(account[from] < amount)
{
sufficientFunds.await();//将该线程放置在这个条件对象的等待集中
}
...
sufficientFunds.signalAll();//解除条件对象的等待集中所有线程的阻塞状态
}
finally
{
bankLock.unlock();//解锁
}
}
}
请注意:
- 一个线程从阻塞状态到达活动状态需要同时满足两个条件:获得了锁 & 不在条件对象的等待集中。
- await方法使得线程暂停,并会放弃锁。
- 线程调用await方法后没有任何办法能够自行激活,只能依靠其他线程的signalAll / signal方法。
- 除 void signalAll()方法外,还存在一个signal()方法,它的作用是随机解除一个线程的阻塞状态。
七、synchronized关键字
synchronized关键字与ReentranceLock对象实现同步的机制非常像,只不过synchronized使用的是内部锁。Java中每一个对象都有一个内部锁,如果一个方法声明时有synchronized关键字,那么对象的内部锁将会保护整个方法。
改写六中的transfer方法:
public class Bank
{
...
public synchronized void transfer(int from, int to, int amount) throws InterruptedException
{
while(account[from] < amount)
{
wait();
}
...
notifyAll();
}
}
这种方式获取的锁是本对象的内部锁。
或者使用同步块:
private Object lock = new Object();
public void transfer(int from, int to, double amount)
{
synchronized(lock)
{
while(account[from] < amount)
{
wait();
}
...
notifyAll();
}
}
这种方式获取的锁是对象 lock 的内部锁。
请注意:
wait、notifyAll、notify方法是Object类中的final方法。而await、signalAll、signal方法为Condition类中的方法。
八、volatile关键字
上文提到,实现同步的两种解决思路:
解决思路:
- 确保该方法一次只能由一个线程调用。
- 一个变量改变时,其他线程可以看见这个改变。
synchronized关键字与ReentranceLock是思路一的具体实现,而volatile是思路二的体现。它的作用是:如果声明一个字段为volatile,那么编译器和虚拟机就知道该字段可能被多个线程并发更新。编译器会插入适当代码,以确保一个线程对该字段作出修改,其他线程能够看见这个改动。
举例:
private volatile boolean done;
public boolean isDone(){return done;}
public void setDone(){done = true;}
一旦一个线程调用setDone方法修改done值为 true,其他线程能够立刻看见这个改动,isDone方法的返回值会变为true。