从我第一篇Java学习笔记系列开始,到现在所写的程序全是单线程程序,也就是程序从main()进入到结束只有一个流程,有时候我们需要设计程序拥有多个流程,也就是我要说的多线程(multi-thread)程序。
线程简介
我们先来看一个龟兔赛跑的例子(单线程实现):
题目要求:
设计一个龟兔赛跑游戏,赛程长度为10歩,每经过一秒,乌龟前进一步,兔子则可能前进两歩也有可能睡觉。
import static java.lang.System.out;
public class TortoiseHareRace {
public static void main(String[] args){
boolean[] flags = {true, false};
int totalStep = 10; //总步数
int tortoiseStep = 0; //乌龟步数
int hareStep = 0; //兔子步数
out.println("龟兔赛跑开始...");
while(tortoiseStep < totalStep && hareStep < totalStep){
tortoiseStep++; //乌龟步数加一
out.printf("乌龟跑了 %d 步...\n", tortoiseStep);
boolean isHareSleep = flags[((int) (Math.random()* 10))% 2]; //随机睡觉
if(isHareSleep){
out.println("兔子睡着了");
}else{
hareStep += 2;
out.printf("兔子跑了 %d 步...\n", hareStep);
}
}
}
}
再来看看多线程是怎么实现的:
乌龟:
public class Tortoise implements Runnable {
private int totalstep;
private int step;
public Tortoise(int totalstep){
this.totalstep = totalstep;
}
@Override
public void run(){
while(step < totalstep){
step++;
System.out.printf("乌龟跑了 %d 歩\n", step);
}
}
}
兔子:
public class Hare implements Runnable {
private boolean[] flags = {true, false};
private int totalstep;
private int step;
public Hare(int totalstep){
this.totalstep = totalstep;
}
public void run(){
while(step < totalstep){
boolean isHareSleep = flags[((int) (Math.random()* 10))% 2];
if(isHareSleep){
System.out.println("兔子睡着了");
}else{
step += 2;
System.out.printf("兔子跑了 %d 歩\n", step);
}
}
}
}
Main函数:
public class TortoiseHareRace2 {
public static void main(String[] args){
Tortoise tortoise = new Tortoise(10);
Hare hare = new Hare(10);
Thread tortoisethread = new Thread(tortoise);
Thread harethread = new Thread(hare);
tortoisethread.start();
harethread.start();
}
}
从上面的代码中我们可以看到线程使用方面一些基本的语法,在这里我就不再赘述。
其实,在Java中我们想要在main()以外设计独立的流程,可以攥写操作类接口java.lang.Runnable,流程的进入点是run方法。
Thread与Runnable
一般来说,我们可以认为JVM是一台虚拟的计算机,(粗略认为)但他只安装了一颗成为主线程的CPU,可执行main()定义的执行流程,如果想为JVM加装CPU,就要创建Thread实例,要启动额外CPU就要调用Thread的start方法。额外CPU的执行流程进入点,可以定义在Runnable接口的run方法中。
除了将流程定义在Runnable的run方法中,我们还可以继承Thread类,重新定义run方法,我建议使用第一种方法,因为我们操作Runnable接口的好处就是比较有弹性,你的类还有机会继承其他类。若我们继承了Thread之后,那我们的类就只是一种Thread,一般我们为了直接利用Thread中定义的方法,才会选择继承Thread。
线程生命周期
Daemon线程
主线程会从main方法开始执行,知道main方法结束之后停止JVM。如果主线程中启动了额外的线程,默认会等待被启动的所有线程都执行完run方法才终止JVM。如果一个线程被标示为Daemon线程,在所有的非Daemon线程都结束时,JVM就会自动终止。
我们可以使用setDaemon方法来设定一个线程是否为Daemon线程,来看一个例子:
public class DaemonDemo {
public static void main(String[] args){
Thread thread = new Thread(){
public void run(){
while(true){
System.out.println("Orz");
}
}
};
thread.setDaemon(true); //把此线程定义为Daemon线程
thread.start();
}
}
如果我们没有使用setDaemon设定为true,那么程序会不断的输出Orz而不终止。使用isDaemon方法可以判断这个线程是否为Daemon线程。
我们默认所有的Daemon线程产生的线程也是Daemon线程。
Thread基本状态图
在调用Thread实例start方法后,基本状态为可执行(Runnable),被阻断(Blocked),执行中(Running)。
实例化Thread并执行start方法之后,线程进入Runnable状态,此时线程尚未真正开始执行run方法,必须等待排班器(Scheduler)排入CPU执行,线程才会执行run方法,进入Running状态。线程看起来是同时执行,但实际上同一时间点上,一个CPU还是只能执行一个线程,只是CPU会不断切换线程,且切换的动作很快,所以看起来像是同时执行。
线程具有优先权,可以使用Thread的setPriority方法设定优先权,可设定值在1到10之间,默认是5,超出1到10的设定值会抛出IllegalArgumentException,数字越大优先权越高,排班器越优先排入CPU,如果优先权相同,则输流执行。
有几种状况会使线程进入Blocked,如调用Thread.sleep(),进入synchronized前竞争对象锁定的阻断,调用wait方法的阻断,等待输入输出完成。运用多线程,当某线程进入Blocked时,让另一线程排入CPU执行(成为Running),避免CPU空闲下来,经常是改进效能的方式之一。
线程因为输入/输出进入Blocked状态,在完成输入/输出之后,会回到Runnable状态,等待排班器排入执行(Running状态)。一个进入Blocked状态的线程,可以由另一个线程调用该线程的interrupt方法,让他离开Blocked状态。
举个例子来说,使用Thread.sleep()会使线程进入Blocked状态,现在有其他线程调用该线程的interrupt方法,会抛出InterruptedException异常对象,这是让线程醒过来的方式。来看一个简单的范例:
public class InterruptedDemo {
public static void main(String[] args){
Thread thread = new Thread(){
@Override
public void run(){
try{
Thread.sleep(99999);
}catch (InterruptedException ex){
System.out.println("我醒了");
}
}
};
thread.start();
thread.interrupt(); //主线程调用thread的interrupt()
}
}
安插线程
如果A线程正在运行,流程中允许B线程加入,等到B线程执行完毕之后在继续A线程流程,可以使用join方法完成这个 需求。
当线程使用join方法加入至另一线程的时候,另一线程会等待被加入的线程完成工作,然后继续它的动作。来看一个例子:
public class JoinDemo {
public static void main(String[] args) throws InterruptedException{
out.println("Main thread 开始...");
Thread threadB = new Thread(() -> {
out.println("Thread B开始...");
for (int i = 0; i < 5; i++){
out.println("Thread B 执行...");
}
out.println("Thread B 结束...");
});
threadB.start();
threadB.join(); //将线程B加入主线程流程
out.println("Main thread 将结束...");
}
}
有时候可能加入的线程处理太久,你不想无止境等待这个线程工作完毕,则可以在join()时指定时间,如join(10000),着表示加入流程的线程至多可以处理10000毫秒,也就是10秒,如果加入的线程还没有执行完毕就不管他了,目前线程可继续执行原本的工作流程。
停止线程
线程完成run方法之后,就会进入Dead,进入Dead(或已经调用过start方法)的线程不可以再次调用start方法,否则会抛出IllegalThreadStateException。
Thread类上有定义stop方法,不过被标示为Deprecated,这表示这些API虽然有,但由于一些原因并没有删除它,所以写程序的时候是十分不建议使用这样的API,这样的API还有像线程的暂停,重启(suspend(), resume())等,如果我们要停止线程或者是暂停,重启必须视需求操作,让线程跑完应有的流程,而非直接停止!
关于ThreadGroup
每个线程都属于某个线程组群。若在main方法中产生一个线程,该线程会属于main线程组。可以使用以下程序获得目前线程所属的线程群组名:
Thread.currentThread().getThreadGroup().getName();
每个线程产生时,如果没有规定所属群组,则归入产生该子线程的线程群组。线程一旦归入某个群组,就无法再更换。
我们可以使用java.lang.ThreadGroup类来管理群组中的线程。可以使用以下方式产生群组,并在产生线程时指定所属群组:
ThreadGroup group1 = new ThreadGroup("group1");
ThreadGroup group2 = new ThreadGroup("group2");
Thread thread1 = new Thread(group1, "group1's member");
Thread thread2 = new Thread(group2, "group2's member");
ThreadGroup中的某些方法,可以对群组中的所有线程产生作用。例如,interrupt方法可以中断群组中的所有线程,setMaxPriority方法可以设定群组中所有线程的最大优先权。
如果想要一次取得群组中的所有线程,可以使用enumerate方法。
Thread[] threads = new Thread[threadGroup1.activeCount()];
threadGroup1.enumerate(threads);
activeCount方法取得群组中的线程数量,enumerate方法要传入Thread数组,这会将线程对象设定至每个数组索引。
ThreadGroup中还有个uncaughtException方法,群组中某个线程发生异常儿未捕捉时,JVM会调用此方法进行处理。如果ThreadGroup还有父ThreadGroup,就会调用父ThreadGroup的uncaughtException方法,否则看看是否为ThreadDead实例。若是则什么都不做,若不时则调用异常的printStrackTrace方法。如果必须定义ThreadGroup中线程的异常处理行为,可以重新定义次方法,例如:
public class ThreadGroupDemo {
public static void main(String[] args){
ThreadGroup group = new ThreadGroup("group") {
@Override
public void uncaughtException(Thread thread, Throwable throwable) { //得到异常线程,异常信息
System.out.printf("%s: %s\n", thread.getName(), throwable.getMessage());
}
};
Thread thread = new Thread(group, () -> {
throw new RuntimeException("测试异常");
});
thread.start();
}
}
uncaughtException方法的第一个参数可取得发生异常的线程实例,第二个参数可取的异常对象。
在JDK5之后,如果线程组之中的线程发生异常,uncaughtException方法处理顺序是:
- 如果ThreadGroup有父ThreadGroup,就会调用父ThreadGroup的uncaughtException方法;
- 否则看看Thread是否使用setUncaughtExceptionHandler方法设定Thread.UncaughtExceptionHandler实例,有的话就会调用其uncaughtException方法;
- 否则看看异常是否为ThreadDead实例,若是的话则什么都不做,若不是的话调用异常的printStrackTrace方法。
synchronized与volatile
如果一个类会使线程存取同一对象相同资源时因发竞速现象,我们就说这个类是不具备线程安全的类,具体的例子我不在贴出,有疑问的读者可以自行百度。
使用synchronized
每个对象都会有个内部锁定,或称为监控锁定。被标示为synchronized的区块将会被监控,任何线程要执行synchronized区块都必须先取得指定的对象锁定。如果线程A已经取得对象锁定开始执行synchronized区块,B线程也想执行synchronized区块,会因无法取得对象锁定而进入等待锁定的状态,直到A线程释放锁定,B线程才有机会取得锁定对象儿执行synchronized区块。
实际上在等待对象锁定的时候,线程也会进入Blocked状态。线程若因尝试执行synchronized区块而进入Blocked,在取得锁定之后,会先回到Runnable状态,等待CPU排班器排入Running状态。
在之前讨论的Collection与Map都未考虑线程安全,可以使用Collections的synchronizedCollection(),synchronizedList(),synchronizedSet(),synchronizedMap()等方法,这些方法会将传入的Collection,List,Set,Map操作对象打包,返回具线程安全的对象。例如我们如果经常对List进行添加和移除的工作:
List<String> list = new ArrayList<>();
synchronized(list){
...
list.add("...");
}
...
synchronized(list){
...
list.remove("...");
}
那么我们可以这样简化:
List<String> list = Collection.synchronizedList(new ArrayList<String>());
在Java中的synchronized提供的是可重入同步,也就是线程取得某对象锁定之后,若执行的过程中又要执行synchronized,尝试取得锁定的对象来源又是同一个,可以直接执行。
因为线程无法取得锁定时会造成阻断,所以不正确的使用synchronized有可能造成效能低落,另一个问题则是死结。例如:有些资源在多线程下交叉使用,就有可能造成死结。来看一个例子:
class Resource{
private String name;
private int resource;
Resource(String name, int resource){
this.name = name;
this.resource = resource;
}
String getName(){
return name;
}
synchronized int doSome(){
return ++resource;
}
synchronized void cooperate(Resource resource){
resource.doSome();
System.out.printf("%s: 整合 %s 的资源\n", this.name, resource.getName());
}
}
public class DeadLockDemo {
public static void main(String[] args){
Resource resource1 = new Resource("resource1", 10);
Resource resource2 = new Resource("resource2", 20);
Thread thread1 = new Thread(() -> {
for(int i = 0; i < 10 ; i++){
resource1.cooperate(resource2);
}
});
Thread thread2 = new Thread(() -> {
for(int i = 0; i < 20 ; i++){
resource2.cooperate(resource1);
}
});
thread1.start();
thread2.start();
}
}
上面的程序发生死结是几率问题。多次执行后会发现,又是程序可顺利执行完成,有时程序会整个停顿。
造成上面的原因在于,thread1调用resource1.corportate(resource2)时,thread1会取得resource1的锁定,若此时thread2也调用resource2.corportate(resource1),就会取得resource2的锁定,凑巧的是thread1现在打算运用穿入的resource2调用doSome(),那么它就会尝试取得resource2的锁定,但是现在resource2的锁定在thread2手上,所以thread1就会被阻塞,相同的,resource1被传入resource2的方法之中,那么现在thread2就会尝试取得resource1的锁定,但是resource1的锁定在thread1手上,所以thread2也会被阻塞,最后就造成死结的情况。
我们在程序设计中,应尽量避免死结的发生。
使用volatile
synchronized要求达到所标示区块的互斥性与可见性,互斥性是指synchronized区块同时间只能有一个线程,可见性是指线程离开synchronized区块后,另一线程接触到的就是上一线程改变后的对象状态。
对与可见性的要求可以使用volatile达到变量范围。在讨论变量的可见性之前,我们先来看一个例子:
class Variable1 {
static int i = 0, j = 0;
static void one(){
i++;
j++;
}
static void two(){
System.out.printf("i = %d, j = %d\n", i, j);
}
}
public class Variable1Test{
public static void main(String[] args){
Thread thread1 = new Thread(() -> {
while(true){
Variable1.one();
}
});
Thread thread2 = new Thread(() -> {
while(true){
Variable1.two();
}
});
thread1.start();
thread2.start();
}
}
在这个程序运行之后有可能出现j远大于i的结果。当thread2调用Variable1.two()取得i值之后,有可能切换thread1不断执行Variable1.one()多次,在切回thread2,就有可能发生这种情况。
我们可以像之前那样给one,two方法上标示synchronized,这样每次thread1调用one时,thread2就必须等待thread1释放锁定,才能调用two,thread2调用two时,thread1就必须等待thread2释放锁定,才能调用one。这样的确可以解决问题,但是当我们调用one时,其他线程就不能调用two,反之亦然。这样会看到执行速度明显变慢。
对于为何j会大于i,这和线程的“快取”有关,在这里不详细描述,要阻止这种情况的发生,我们可以在变量上声明volatile,表示变量是不稳定的,易变的,但即使这样,也只是提高了i,j相等的几率。被标示为volatile的变量不允许线程快取。实际上,如果我们要保证i,j相等的话,就要使用synchronized。
等待与通知
wait(),notify(),notifyAll()是Object定义的方法,可以通过这三个方法控制线程释放对象的锁定,或者通知线程参与锁定竞争。
在线程执行synchronized范围的程序代码期间,若调用锁定对象的wait方法,线程会释放对象锁定,并进入对象等待集合而处于阻断状态,其他线程可以竞争对象锁定。
放在等待集合的线程不会参与CPU排班,wait()可以指定等待时间,时间到了之后线程会再次加入排班,如果指定时间0或不指定,则线程会持续等待,直到被中断(调用interrupt())或是告知(notify())可以参与排班。
被竞争锁定的对象调用notify方法时,会从对象等待集合中随机通知一个线程加入排班,如果调用notifyAll方法,所有等待集合中的线程都会被通知参与排班。
线程调用对象wait方法时,会先让出synchronized区块的使用权并等待通知,或是等待指定时间,直到被notify方法或时间到时,(取得对象锁定之后)在从调用wait()处开始执行。
来举一个生产者,店员,消费者之间的例子,消费者每次生产一个int整数交给店员:
public class Producer implements Runnable{
private Clerk clerk;
public Producer(Clerk clerk){
this.clerk = clerk;
}
public void run(){
System.out.println("生产者开始生产整数... ...");
for(int product = 1; product <= 10; product++){
try{
clerk.setProduct(product); //将产品交给店员
}catch (InterruptedException ex){
throw new RuntimeException(ex);
}
}
}
}
程序中使用for循环生产1~10的整数,Clerk代表店员,可通过setProduct方法将生产的整数交给店员。
消费者从店员处取走int整数:
public class Consumer implements Runnable {
private Clerk clerk;
public Consumer(Clerk clerk){
this.clerk = clerk;
}
public void run(){
System.out.println("消费者开始消耗整数... ...");
for(int i = 1; i <= 10; i++){
try{
clerk.getProduct(); //从店员处取走产品
}catch (InterruptedException ex){
throw new RuntimeException(ex);
}
}
}
}
程序中使用for循环来消费10次整数,可通过Clerk的getProduct方法,从店员处取走整数。
店员一次只能持有一个int整数,必须尽到要求等待与通知的职责:
public class Clerk {
private int product = -1;
public synchronized void setProduct(int product) throws InterruptedException{
waitIfFull(); //看看店员有没有空间收产品,没有的话就稍后
this.product = product;
System.out.printf("生产者设定 %d \n", this.product);
notify(); //通知等待中的线程(消费者)
}
private synchronized void waitIfFull() throws InterruptedException{
while(this.product != -1){ //店员由产品,没有空间
wait();
}
}
public synchronized int getProduct() throws InterruptedException{
waitIfEmpty(); //看看店员有没有货,没有的话就稍后
int p = this.product;
this.product = -1; //表示货物被取走
System.out.printf("消费者取走 %d \n", p);
notify(); //通知等待集合中的线程(生产者)
return p;
}
private synchronized void waitIfEmpty() throws InterruptedException{
while(this.product == -1){
wait();
}
}
}
因为线程有可能在未经notify方法,interrupt方法或逾时情况下私自苏醒,所以wait方法一定要在条件式成立的循环中执行。
用以下程序示范生产者,消费者,店员:
public class ProducerConsumerDemo {
public static void main(String[] args){
Clerk clerk = new Clerk();
new Thread(new Producer(clerk)).start();
new Thread(new Consumer(clerk)).start();
}
}
程序的运行结果希望大家自己能够尝试一下。