16.5.3 同步方法

与同步代码块对应,Java的多线程安全支持还提供了同步方法,

什么是同步方法

同步方法就是使用synchronized关键字修饰的方法

同步资源监视器 是 调用该同步方法的 对象

对于synchronized修饰的实例方法(非static方法)而言,无须显式指定同步监视器,同步方法的同步监视器默认是this,也就是调用该同步方法的对象.

线程安全类

线程安全类的特征

通过使用同步方法可以非常方便地实现线程安全的类,线程安全的类具有如下特征:

  1. 该类的对象可以被多个线程安全地访问
  2. 每个线程调用该对象的任意方法之后都将得到正确结果。
  3. 每个线程调用该对象的任意方法之后,该对象状态依然保持合理状态。

不可变类对象总是线程安全

前面介绍了可变类和不可变类,其中不可变类总是线程安全的,因为它的对象状态不可改变;

可变类

可变对象需要额外的方法来保证其线程安全。
例如上面的Account就是一个可变类,它的**accountNobalance两个成员变量都可以被改变**,当两个线程同时修改Account对象的balance成员变量的值时,程序就出现了异常。
Account类对balance的访问设置成线程安全的,那么只要把修改balance的方法变成同步方法即可

程序 同步方法

修改账户类

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public class Account
{
// 封装账户编号、账户余额两个成员变量
private String accountNo;
private double balance;
public Account(){}
// 构造器
public Account(String accountNo , double balance)
{
this.accountNo = accountNo;
this.balance = balance;
}

// accountNo的setter和getter方法
public void setAccountNo(String accountNo)
{
this.accountNo = accountNo;
}
public String getAccountNo()
{
return this.accountNo;
}
// 因为账户余额不允许随便修改,所以只为balance提供getter方法,
public double getBalance()
{
return this.balance;
}

// 提供一个线程安全draw()方法来完成取钱操作
public synchronized void draw(double drawAmount)
{
// 账户余额大于取钱数目
if (balance >= drawAmount)
{
// 吐出钞票
System.out.println(Thread.currentThread().getName()
+ "取钱成功!吐出钞票:" + drawAmount);
try
{
Thread.sleep(1);
}
catch (InterruptedException ex)
{
ex.printStackTrace();
}
// 修改余额
balance -= drawAmount;
System.out.println("\t余额为: " + balance);
}
else
{
System.out.println(Thread.currentThread().getName()
+ "取钱失败!余额不足!");
}
}

// 下面两个方法根据accountNo来重写hashCode()和equals()方法
public int hashCode()
{
return accountNo.hashCode();
}
public boolean equals(Object obj)
{
if(this == obj)
return true;
if (obj !=null && obj.getClass() == Account.class)
{
Account target = (Account)obj;
return target.getAccountNo().equals(accountNo);
}
return false;
}
}

上面程序中增加了一个代表取钱的draw()方法,并使用了synchronized关键字修饰该方法,把该方法变成同步方法,该同步方法的同步监视器是this,因此对于同一个Account账户而言,任意时刻只能有一个线程获得对Account对象的锁定,然后进入draw()方法执行取钱操作—这样也可以保证多个线程并发取钱的线程安全

修改取钱线程

因为Account类中已经提供了draw()方法,而且取消了setBalance()方法,DrawThread线程类需要改写,该线程类的run()方法只要调用Account对象的draw()方法即可执行取钱操作。run()方法代码片段如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class DrawThread extends Thread {
// 模拟用户账户
private Account account;
// 当前取钱线程所希望取的钱数
private double drawAmount;

public DrawThread(String name, Account account, double drawAmount) {
super(name);
this.account = account;
this.drawAmount = drawAmount;
}

// 当多条线程修改同一个共享数据时,将涉及数据安全问题。
public void run() {
// 直接调用account对象的draw方法来执行取钱
// 同步方法的同步监视器是this,this代表调用draw()方法的对象。
// 也就是说:线程进入draw()方法之前,必须先对account对象的加锁。
account.draw(drawAmount);
}
}

上面的DrawThread类无须自己实现取钱操作,而是直接调用accountdraw()方法来执行取钱操作。由于已经使用synchronized关键字修饰了draw()方法,同步方法的同步监视器是this,而this总代表调用该方法的对象—在上面示例中,调用draw()方法的对象是account,因此多个线程并发修改同一份account之前,必须先对account对象加锁。这也符合了”加锁→修改→释放锁”的逻辑。

Account里定义draw()方法,而不是直接在run()方法中实现取钱逻辑,这种做法更符合面向对象规则。

领域驱动设计

在面向对象里有一种流行的设计方式:Domain Driven Design(领域驱动设计,DDD),这种方式认为每个类都应该是完备的领域对象,例如Account代表用户账户,应该提供用户账户的相关方法;通过draw()方法来执行取钱操作(实际上还应该提供transfer等方法来完成转账等操作),而不是直接将setBalance()方法暴露出来任人操作,这样才可以更好地保证Account对象的完整性和一致性.

如何减少线程安全的负面影响

只同步共享资源

可变类的线程安全是以降低程序的运行效率作为代价的,为了减少线程安全所带来的负面影响,程序可以采用如下策略。
不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源的方法进行同步(竞争资源也就是共享资源)。

  • 例如上面Account类中的accountNo实例变量就无须同步,所以程序只对draw()方法进行了同步控制。

为单线程和多线程提供不同版本

  • 如果可变类有两种运行环境:单线程环境和多线程环境,则应该为该可变类提供两种版本,即线程不安全版本和线程安全版本。
    • 在单线程环境中使用线程不安全版本以保证性能,
    • 在多线程环境中使用线程安全版本。
    • JDK所提供的StringBuilderStringBuffer就是为了照顾单线程环境和多线程环境所提供的类,
      • 在单线程环境下应该使用StringBuilder来保证较好的性能;
      • 当需要保证多线程安全时,就应该使用StringBuffer.

16.5.2 同步代码块

同步代码块

Java的多线程支持引入了同步监视器来解决同步问题,**使用同步监视器的通用方法就是同步代码块**。

同步代码块的语法格式

1
2
3
4
synchronized(object)
{
// 同步代码块的内容...
}

什么是同步监视器

synchronized关键字后面的括号里的参数object就是同步监视器

同步代码块的含义

同步代码块的含义是:
线程开始执行同步代码块之前,必须先获得对同步监视器的锁定

任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完成后,该线程会释放对该同步监视器的锁定。

同步监视器的选择标准

虽然Java程序允许使用任何对象作为同步监视器,但同步监视器的目的是阻止两个线程对同一个共享资源进行并发访问,因此通常推荐使用可能被并发访问的共享资源充当同步监视器

程序 使用同步代码块 同步取钱

对于上面的取钱模拟程序,应该考虑使用账户(account)作为同步监视器,把程序修改成如下形式。

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
public class DrawThread extends Thread {
// 模拟用户账户
private Account account;
// 当前取钱线程所希望取的钱数
private double drawAmount;

public DrawThread(String name, Account account, double drawAmount) {
super(name);
this.account = account;
this.drawAmount = drawAmount;
}

// 当多条线程修改同一个共享数据时,将涉及数据安全问题。
public void run() {
// 使用account作为同步监视器,任何线程进入下面同步代码块之前,
// 必须先获得对account账户的锁定——其他线程无法获得锁,也就无法修改它
// 这种做法符合:“加锁 → 修改 → 释放锁”的逻辑
synchronized (account) {
// 账户余额大于取钱数目
if (account.getBalance() >= drawAmount) {
// 吐出钞票
System.out.println(getName() + "取钱成功!吐出钞票:" + drawAmount);
try {
Thread.sleep(1);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
// 修改余额
account.setBalance(account.getBalance() - drawAmount);
System.out.println("\t余额为: " + account.getBalance());
} else {
System.out.println(getName() + "取钱失败!余额不足!");
}
}
// 同步代码块结束,该线程释放同步锁
}
}

上面程序使用synchronizedrun()方法里的方法体修改成同步代码块,该同步代码块的同步监视器是account对象,这样的做法符合”加锁→修改→释放锁”的逻辑。

加锁 修改 释放锁

任何线程在修改指定资源之前,

  • 首先对该资源加锁,在加锁期间其他线程无法修改该资源,
  • 当该线程修改完成后,
  • 该线程释放对该资源的锁定。

通过这种方式就可以保证并发线程在任一时刻只有一个线程可以进入修改共享资源的代码区(也被称为临界区),所以同一时刻最多只有一个线程处于临界区内,从而保证了线程的安全性。

DrawThread修改为上面所示的情形之后,多次运行该程序,总可以看到如下的运行结果:

1
2
3
甲取钱成功!吐出钞票:800.0
余额为: 200.0
乙取钱失败!余额不足!

16.5 线程同步

多线程编程很容易突然出现“错误情况”,这是由系统的线程调度具有一定的随机性造成的,不过即使程序偶然出现问题,那也是由于编程不当引起的。当使用多个线程来访问同一个数据时,很容易“偶然”出现线程安全问题

16.5.1 线程安全问题

银行取钱问题

关于线程安全问题,有一个经典的问题——银行取钱的问题。银行取钱的基本流程基本上可以分为如下几个步骤

  1. 用户输入账户、密码,系统判断用户的账户、密码是否匹配。
  2. 用户输入取款金额
  3. 系统判断账户余额是否大于取款金额
    1. 如果余额大于取款金额,则取款成功;
    2. 如果余额小于取款金额,则取款失败

乍一看上去,这个流程确实就是日常生活中的取款流程,这个流程没有任何问题。但一旦将这个流程放在多线程并发的场景下,就有可能出现问题。注意此处说的是有可能,并不是说一定。也许你的程序运行了一百万次都没有出现问题,但没有出现问题并不等于没有问题!

程序 银行取钱问题

按上面的流程去编写取款程序,并使用两个线程来模拟取钱操作,模拟两个人使用同一个账户并发取钱的问题。此处忽略检査账户和密码的操作,仅仅模拟后面三步操作。

账户类

下面先定义一个账户类,该账户类封装了账户编号和余额两个实例变量。

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
public class Account
{
// 封装账户编号、账户余额的两个成员变量
private String accountNo;
private double balance;
public Account(){}
// 构造器
public Account(String accountNo , double balance)
{
this.accountNo = accountNo;
this.balance = balance;
}
// 此处省略getter和setter方法,请自己补上
// 下面两个方法根据accountNo来重写hashCode()和equals()方法
public int hashCode()
{
return accountNo.hashCode();
}
public boolean equals(Object obj)
{
if(this == obj)
return true;
if (obj !=null
&& obj.getClass() == Account.class)
{
Account target = (Account)obj;
return target.getAccountNo().equals(accountNo);
}
return false;
}
}

取钱线程

接下来提供一个取钱的线程类,该线程类根据执行账户、取钱数量进行取钱操作,取钱的逻辑是当其余额不足时无法提取现金,当余额足够时系统吐出钞票,余额减少。

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
public class DrawThread extends Thread
{
// 模拟用户账户
private Account account;
// 当前取钱线程所希望取的钱数
private double drawAmount;
public DrawThread(String name , Account account
, double drawAmount)
{
super(name);
this.account = account;
this.drawAmount = drawAmount;
}
// 当多条线程修改同一个共享数据时,将涉及数据安全问题。
public void run()
{
// 账户余额大于取钱数目
if (account.getBalance() >= drawAmount)
{
// 吐出钞票
System.out.println(getName()
+ "取钱成功!吐出钞票:" + drawAmount);
try
{
Thread.sleep(1);
}
catch (InterruptedException ex)
{
ex.printStackTrace();
}
// 修改余额
account.setBalance(account.getBalance() - drawAmount);
System.out.println("\t余额为: " + account.getBalance());
}
else
{
System.out.println(getName() + "取钱失败!余额不足!");
}
}
}

上面程序是一个非常简单的取钱逻辑,这个取钱逻辑与实际的取钱操作也很相似。

主程序

程序的主程序非常简单,仅仅是创建一个账户,并启动两个线程从该账户中取钱。如下所示:

1
2
3
4
5
6
7
8
9
10
11
public class DrawTest
{
public static void main(String[] args)
{
// 创建一个账户
Account acct = new Account("1234567" , 1000);
// 模拟两个线程对同一个账户取钱
new DrawThread("甲" , acct , 800).start();
new DrawThread("乙" , acct , 800).start();
}
}

运行结果

多次运行上面程序,很有可能都会看到如下所示的错误结果

1
2
3
4
甲取钱成功!吐出钞票:800.0
乙取钱成功!吐出钞票:800.0
余额为: -600.0
余额为: -600.0

账户余额出现了负值,这不是银行希望的结果。

16.4.5 改变线程优先级

优先级的作用

每个线程执行时都具有一定的优先级,优先级高的线程获得较多的执行机会,而优先级低的线程则获得较少的执行机会

默认优先级

每个线程默认的优先级都与创建它的父线程的优先级相同,在默认情况下,main线程具有普通优先级,所以由main线程创建的子线程也具有普通优先级。

优先级方法

Thread类提供了setPrioritygetPriority方法来设置和返回指定线程的优先级:

Thread类方法 描述
void setPriority(int newPriority) Changes the priority of this thread.
int getPriority() Returns this thread’s priority.

其中setPriority()方法的参数可以是一个整数,范围是1-10之间,也可以使用Thread类提供的三个静态常量来设置优先级,如下所示:

  • MAX_PRIORITY,其值是10
  • NORM_PRIORITY,其值是5
  • MIN_PRIORITY,其值是1

程序 设置线程优先级

下面程序使用了setPriority()方法来改变主线程的优先级,并使用该方法改变了两个线程的优先级,从而可以看到高优先级的线程将会获得更多的执行机会

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
public class PriorityTest extends Thread {
// 定义一个有参数的构造器,用于创建线程时指定name
public PriorityTest(String name) {
super(name);
}

public void run() {
for (int i = 0; i < 50; i++) {
System.out.println(getName() + ",其优先级是:" + getPriority() + ",循环变量的值为:" + i);
}
}

public static void main(String[] args) {
// 改变主线程的优先级
Thread.currentThread().setPriority(6);
for (int i = 0; i < 30; i++) {
if (i == 10) {
PriorityTest low = new PriorityTest("低级");
low.start();
System.out.println("创建之初的优先级:" + low.getPriority());
// 设置该线程为最低优先级
low.setPriority(Thread.MIN_PRIORITY);
}
if (i == 20) {
PriorityTest high = new PriorityTest("高级");
high.start();
System.out.println("创建之初的优先级:" + high.getPriority());
// 设置该线程为最高优先级
high.setPriority(Thread.MAX_PRIORITY);
}
}
}
}

上面程序中改变了主线程的优先级为6,这样由main线程所创建的子线程的优先级默认都是6,所以程序直接输出lowhigh两个线程的优先级时应该看到6。
接着程序将low线程的优先级设为Priority.MIN_PRIORITY,将high线程的优先级设置为Priority.MAX_PRIORITY

优先级级别需要操作系统支持

值得指出的是,虽然Java提供了10个优先级级别,但这些优先级级别需要操作系统的支持
遗憾的是,不同操作系统上的优先级并不相同,而且也不能很好地和Java的10个优先级对应,例如**Windows 2000仅提供了7个优先级**。

应该尽量避免直接为线程指定优先级

因此应该尽量避免直接为线程指定优先级,而应该使用Thread.MAX_PRIORITYThread.NORM_PRIORITYThread.MIN_PRIORITY三个静态常量来设置优先级,这样才可以保证程序具有最好的可移植性。

16.4.4 线程让步yield

yield方法

yield方法是一个和sleep方法有点相似的方法,它也是Thread类提供的一个静态方法,yield也可以让当前正在执行的线程暂停,但yield不会阻塞该线程,yield只是将该线程转入就绪状态。
这里有一张图片

方法 描述
static void yield() A hint to the scheduler that the current thread is willing to yield its current use of a processor.

yield只是让当前线程暂停一下,让系统的线程调度器重新调度一次,完全可能的情况是:当某个线程调用了yield方法暂停之后,线程调度器又将其调度出来重新执行。

yield后优先级相同或优先级更高的得到执行机会

实际上,当某个线程调用了yield方法暂停之后,只有优先级与当前线程相同,或者优先级比当前线程更高的处于就绪状态的线程才会获得执行的机会。

程序 yield方法

下面程序使用yield方法来让当前正在执行的线程暂停。

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
public class YieldTest extends Thread
{
public YieldTest(String name)
{
super(name);
}
// 定义run方法作为线程执行体
public void run()
{
for (int i = 0; i < 50 ; i++ )
{
System.out.println(getName() + " " + i);
// 当i等于20时,使用yield方法让当前线程让步
if (i == 20)
{
Thread.yield();//代码0
}
}
}
public static void main(String[] args)throws Exception
{
// 启动两条并发线程
YieldTest yt1 = new YieldTest("高级");
// 将ty1线程设置成最高优先级
// yt1.setPriority(Thread.MAX_PRIORITY);//代码1
yt1.start();
YieldTest yt2 = new YieldTest("低级");
// 将yt2线程设置成最低优先级
// yt2.setPriority(Thread.MIN_PRIORITY);//代码2
yt2.start();
}
}

上面程序中的代码0调用yield静态方法让当前正在执行的线程暂停,让系统线程调度器重新调度。由于程序中代码1、代码2处于注释状态。
此时**两个线程的优先级完全一样,所以当一个线程使用yield方法后,另一个线程就会开始执行**。
运行结果如下所示:

1
2
3
4
5
6
7
...
高级 20
低级 15
...
低级 20
高级 24
...

如果将程序中代码1代码2的注释取消,也就是为两个线程分别设置不同的优先级,则程序的运行结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
....
高级 20
低级 10
高级 21
...
高级 49
低级 17
低级 18
低级 19
低级 20
低级 21
...
低级 49

yield后低优先级的线程依然有可能得到执行

线程让步后,由线程调度器选中就绪状态中的一个线程来执行,优先级高的线程被选中的机会比较大,但也只是机会大而已,低优先级的线程依然有可能得到运行

sleep方法和yield方法的区别

关于sleep方法和yield方法的区别如下:

yield优先级相关

  • sleep方法暂停当前线程后,会给其他线程执行机会,不会理会其他线程的优先级;
    • yield方法只会给优先级相同,或优先级更高的线程执行机会。

sleep先阻塞后就绪 yield直接就绪

  • sleep方法会将线程转入阻塞状态,直到经过阻塞时间才会转入就绪状态;
    • yield不会将线程转入阻塞状态,它只是强制当前线程进入就绪状态。因此完全有可能某个线程调用yield方法暂停之后,立即再次获得处理器资源被执行。

sleep抛异常 yield不抛异常

  • sleep方法声明抛出了InterruptedException异常,所以调用sleep方法时要么捕捉该异常,要么显式声明抛出该异常;
    • yiled方法则没有声明抛出任何异常。
  • sleep方法比yiled方法有更好的可移植性,通常不建议使用yield方法来控制并发线程的执行

总结

  • yiled方法使得线程进入就绪状态,系统线程调度器重新调度处于就绪状态的一个线程来运行,因为调用yiled方法的线程此时也处于就绪状态,所以该线程可能被线程调度器选中得以再次运行.
  • sleep方法使得线程进入阻塞状态,睡眠时间结束后,再进入就绪状态

16.4.3 线程睡眠sleep

sleep方法

如果需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用Thread类的静态sleep方法来实现。
这里有一张图片

sleep方法有如下两种重载形式

方法 描述
static void sleep(long millis) 让当前正在执行的线程暂停millis毫秒,并进入阻塞状态,该方法受到系统计时器和线程调度器的精度与准确度的影响。
static void sleep(long millis, int nanos) 让当前正在执行的线程暂停 millis毫秒加 nanos毫微秒,并进入阻塞状态,该方法受到系统计时器和线程调度器的精度与准确度的影响。一般很少调用这种形式的sleep方法。

当一个线程调用sleep方法进入阻塞状态后,在其睡眠时间段内,该线程不会获得执行的机会,即使系统中没有其他可执行的线程,处于sleep()中的线程也不会执行,因此sleep方法常用来暂停程序的执行。

程序示例 线程sleep

下面程序调用sleep方法来暂停主线程的执行,因为该程序只有一个主线程,当主线程进入睡眠后,系统没有可执行的线程,所以可以看到程序在sleep方法处暂停。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.*;

public class SleepTest
{
public static void main(String[] args)
throws Exception
{
for (int i = 0; i < 10 ; i++ )
{
System.out.println("当前时间: " + new Date());
// 调用sleep方法让当前线程暂停1s。
Thread.sleep(1000);
}
}
}

运行效果

1
2
3
4
5
6
7
8
9
10
当前时间: Wed Jul 17 13:21:24 CST 2019
当前时间: Wed Jul 17 13:21:25 CST 2019
当前时间: Wed Jul 17 13:21:26 CST 2019
当前时间: Wed Jul 17 13:21:27 CST 2019
当前时间: Wed Jul 17 13:21:28 CST 2019
当前时间: Wed Jul 17 13:21:29 CST 2019
当前时间: Wed Jul 17 13:21:30 CST 2019
当前时间: Wed Jul 17 13:21:31 CST 2019
当前时间: Wed Jul 17 13:21:32 CST 2019
当前时间: Wed Jul 17 13:21:33 CST 2019

运行上面程序,看到程序依次输出10条字符串,输出2条字符串之间的时间间隔为1秒。

16.4.2 后台线程

什么是后台线程

定义

后台线程(Daemon Thread),是在后台运行的一种线程,

用途

后台线程主要用来向其他的线程提供服务JVM的垃圾回收线程就是典型的后台线程。

特征

如果所有的前台线程都死亡,后台线程会自动死亡

Thread类后台线程方法

如何把一个线程设置成后台线程

调用Thread对象的setDaemon(true)方法可将指定线程设置成后台线程。

如何判断一个线程是否是后台线程

Thread类还提供了一个isDaemon()方法,用于判断指定线程是否为后台线程。

Thread类方法 描述
void setDaemon(boolean on) Marks this thread as either a daemon thread or a user thread.
boolean isDaemon() Tests if this thread is a 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
public class DaemonThread extends Thread
{
// 定义后台线程的线程执行体与普通线程没有任何区别
public void run()
{
for (int i = 0; i < 1000 ; i++ )
{
System.out.println(getName() + " " + i);
}
System.out.println("后台线程结束");
}
public static void main(String[] args)
{
DaemonThread t = new DaemonThread();
// 将此线程设置成后台线程
t.setDaemon(true);
// 启动后台线程
t.start();
for (int i = 0 ; i < 10 ; i++ )
{
System.out.println(Thread.currentThread().getName()
+ " " + i);
}
// -----程序执行到此处,前台线程(main线程)结束------
// 后台线程也应该随之结束
System.out.println("前台(main)线程结束");
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
main  0
main 1
......
main 7
main 8
Thread-0 0
main 9
前台(main)线程结束
Thread-0 1
Thread-0 2
......
Thread-0 16
Thread-0 17

上面程序中先将t线程设置成后台线程,然后启动该线程,本来该线程应该执行到循环变量i等于999时才会结束,
但从运行结果中不难发现该后台线程无法运行到循环变量i等于999时,因为程序中唯一的前台线程(main线程)运行结束后,JVM会主动退出,因而后台线程也就被结束了

小结

前后台的默认情况

  • 主线程默认是前台线程,
  • 前台线程创建的子线程默认是前台线程,
  • 后台线程创建的子线程默认是后台线程。

后台线程死亡需要一定时间

当所有的前台线程死亡后,JVM会给后台线程发出死亡通知,但后台线程从接收死亡通知到真正死亡,需要一定时间。

要先设置为后台线程 再启动线程

要将某个线程设置为后台线程,必须在该线程启动之前设置,也就是说,setDaemon(true)必须在start()方法之前调用,否则会引发IllegalThreadStateException异常。

16.4 控制线程

Java的线程(Thread类)支持提供了一些便捷的工具方法,通过这些便捷的工具方法可以很好地控制线程的执行.

16.4.1 join线程

join方法

**join方法可以让一个线程等待另一个线程完成**。

join()方法有如下三种重载形式

Thread类的join方法 描述
void join() 等待被join的线程执行完成。
void join(long millis) 等待被join的线程的时间最长为millis毫秒。如果在millis毫秒内被join的线程还没有执行结束,则不再等待。
void join(long millis, int nanos) 等待被join的线程的时间最长为millis毫秒加nanos毫微秒。很少使用这种形式,原因有两个:程序对时间的精度无须精确到毫微秒;计算机硬件、操作系统本身也无法精确到毫微秒。

当前线程 等待 调用join方法的线程

例如,在当前线程中调用如下代码:

1
B.join();

当前线程要等待B线程运行结束.
这个当前线程要看调用join方法的代码位于哪个线程中

  • 如果在主线程的代码中调用B.join(),则主线程必须等待B线程执行完毕
  • 如果在A线程的代码中调用B.join(),则线程A必须等待线程B执行完毕

join方法的作用

join方法通常由使用线程的程序调用,以将大问题划分成许多小问题,每个小问题分配一个线程。当所有的小问题都得到处理后,再调用主线程来进一步操作。

程序 join方法示例

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
public class JoinThread extends Thread {
// 提供一个有参数的构造器,用于设置该线程的名字
public JoinThread(String name) {
super(name);
}

// 重写run()方法,定义线程执行体
public void run() {
for (int i = 0; i <= 30; i++) {
System.out.println(getName() + " " + i);
if(i==30){
System.out.println("================== "+getName()+" 死亡");
}
}
}

public static void main(String[] args) throws Exception {
// 启动子线程
new JoinThread("线程A").start();
for (int i = 0; i < 30; i++) {
if (i == 10) {
JoinThread jt = new JoinThread("线程B");
jt.start();
// main线程中调用了jt线程的join()方法
// main线程必须等jt执行结束才会向下执行
System.out.println("-----------------主线程 等待 线程B 开始---------------------");
jt.join();
System.out.println("-----------------主线程 等待 线程B 结束---------------------");
}
System.out.println("主线程 " + i);
}
System.out.println("================== 主线程 死亡");
}
}

运行效果

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
主线程  0
线程A 0
主线程 1
......
主线程 8
线程A 7
主线程 9
线程A 8
-----------------主线程 等待 线程B 开始---------------------
线程B 0
线程A 9
线程B 1
线程A 10
......
线程A 28
线程B 22
线程A 29
线程B 23
线程A 30
线程B 24
================== 线程A 死亡
线程B 25
线程B 26
......
线程B 29
线程B 30
================== 线程B 死亡
-----------------主线程 等待 线程B 结束---------------------
主线程 10
主线程 11
......
主线程 28
主线程 29
================== 主线程 死亡

分析

上面程序中一共有3个线程:

  • 主线程开始时就启动线程A,然后,线程A将会和主线程并发执行。
  • 主线程的循环变量i等于10时,启动线程B,线程B不会和主线程并发执行,主线程必须等线程B执行结束后才可以向下执行
  • 线程B的线程执行时,实际上只有线程A线程B这2个子线程并发执行,而主线程处于等待状态。

16.3 线程的生命周期

当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。

线程的5种状态

在线程的生命周期中,它要经过:

  1. 新建(New)、
  2. 就绪(Runnable)、
  3. 运行(Running)、
  4. 阻塞(Blocked)
  5. 死亡(Dead)

这5种状态。

尤其是当线程启动以后,它不可能一直霸占着CPU独自运行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行阻塞之间切换。

16.3.1 新建状态和就绪状态

新建状态 new Thread创建线程后

当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的Java对象一样,仅仅由Java虚拟机为其分配内存,并初始化其成员变量的值。
此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。

就绪状态 调用Thread对象start方法后

当线程对象调用了start方法之后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于JVM里线程调度器的调度。

启动线程使用start方法

永远不要调用线程对象的run方法

启动线程使用start()方法,而不是run()方法!永远不要调用线程对象的run()方法!

  • 调用start()方法来启动线程,系统会把该run()方法当成线程执行体来处理;
  • 如果直接调用线程对象的run()方法,系统把线程对象当成一个普通对象,把run()方法当成一个普通方法,而不是线程执行体,run()方法将立即就会被执行,而且在run()方法返回之前其他线程无法并发执行

程序 调用run方法不会启动线程

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
public class InvokeRun extends Thread
{
private int i ;
// 重写run方法,run方法的方法体就是线程执行体
public void run()
{
for ( ; i < 100 ; i++ )
{
// 直接调用run方法时,Thread的this.getName返回的是该对象名字,
// 而不是当前线程的名字。
// 使用Thread.currentThread().getName()总是获取当前线程名字
System.out.println(Thread.currentThread().getName()
+ " " + i); // ①
}
}
public static void main(String[] args)
{
for (int i = 0; i < 100; i++)
{
// 调用Thread的currentThread方法获取当前线程
System.out.println(Thread.currentThread().getName()
+ " " + i);
if (i == 20)
{
// 直接调用线程对象的run方法,
// 系统会把线程对象当成普通对象,run方法当成普通方法,
// 所以下面两行代码并不会启动两条线程,而是依次执行两个run方法
new InvokeRun().run();
new InvokeRun().run();
}
}
}
}

运行效果:

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
main 0
main 1
main 2
main 3
......
main 18
main 19
main 20
main 0
main 1
main 2
......
main 44
main 45
main 46
main 47
main 48
main 49
main 0
main 1
main 2
main 3
......
main 47
main 48
main 49

上面程序创建线程对象后**直接调用了线程对象的run()方法,程序运行的结果是整个程序只有一个线程:主线程**。
还有一点需要指出,如果直接调用线程对象的run()方法,则run()方法里不能直接通过getName()方法来获得当前执行线程的名字,而是需要使用Thread.currentThread()方法先获得当前线程,再调用线程对象的getName()方法来获得线程的名字。
通过上面程序不难看出,启动线程的正确方法是调用Thread对象的start()方法,而不是直接调用run()方法,否则就变成单线程程序了

只能对处于新建状态的线程调用start方法

只能对处于新建状态的线程调用start方法,否则将引发IllegalThreadStateException异常。调用了线程的run()方法之后,该线程已经不再处于新建状态,不要再次调用线程对象的start()方法。
调用线程对象的start方法之后,该线程立即进入就绪状态,就绪状态相当于”等待执行”,但该线程并未真正进入运行状态。

如何让子线程立即执行

如果希望调用子线程的start方法后子线程立即开始执行,程序可以使用Thread.sleep(1)来让当前运行的线程(主线程)睡眠1毫秒——1毫秒就够了,因为在这1毫秒内CPU不会空闲,它会去执行另一个处于就绪状态的线程,这样就可以让子线程立即开始执行。

小结

  • 当程序使用new关键字创建了一个线程之后,该线程就处于新建状态
  • 当线程对象调用了start方法之后,该线程处于就绪状态,就绪状态相当于”等待执行”状态,此时该线程并未真正进入运行状态。
  • 只能对处于新建状态的线程调用start方法,否则将引发IllegalThreadStateException异常
  • 启动线程的正确方法是调用Thread对象的start()方法,而不是直接调用run()方法,直接调用了线程对象的run()方法,程序运行的结果是整个程序只有一个线程:主线程
  • 如果希望调用子线程的start方法后子线程立即开始执行,则可以让当前运行的线程(主线程)睡眠1毫秒,这种情况针只有一个子线程的情况,个人觉得用处不大.

16.3.2 运行状态和阻塞状态

运行状态 run方法得到执行

如果处于就绪状态的线程获得了CPU,开始执行线程的执行体run()方法,则该线程处于运行状态,如果计算机只有一个CPU,那么在任何时刻只有一个线程处于运行状态。当然,在一个多处理器的机器上,将会有多个线程**并行执行**;不过当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象。(注意多处理器的机器上是并行:parallel,单处理器上是并发)
当一个线程开始运行后,它不可能一直处于运行状态(除非它的线程执行体足够短,瞬间就执行结束了),线程在运行过程中需要被中断,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略。对于采用抢占式策略的系统而言,系统会给每个可执行的线程一个小时间段来处理任务;当该时间段用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。在选择下一个线程时,系统会考虑线程的优先级。
所有现代的桌面和服务器操作系统都采用抢占式调度策略,但一些小型设备如手机则可能采用协作式调度策略,在这样的系统中,只有当一个线程调用了它的sleep()yield()方法后才会放弃所占用的资源,也就是必须由该线程主动放弃所占用的资源。

阻塞状态 sleep IO阻塞 等待同步锁 等待通知 suspend

当发生如下情况时,线程将会进入阻塞状态。

  1. 线程调用sleep()方法主动放弃所占用的处理器资源。
  2. 线程调用了一个阻塞式IO方法,在阻塞式IO方法返回之前,该线程被阻塞。
  3. 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有的时候
  4. 线程在等待某个通知(notify)。
  5. 程序调用了线程的suspend()方法将该线程挂起。但这个方法容易导致死锁,所以应该尽量避免使用该方法。

阻塞结束进入就绪

当前正在执行的线程被阻塞之后,其他线程就可以获得执行的机会。被阻塞的线程会在合适的时候重新进入就绪状态,注意是就绪状态而不是运行状态。也就是说,被阻塞线程的阻塞解除后,必须重新等待线程调度器再次调度它

阻塞进入就绪 sleep时间到 IO方法返回 获得同步锁 收到通知 resumed

针对上面几种情况,当发生如下特定的情况时可以解除上面的阻塞,让该线程重新进入就绪状态。

  1. 调用sleep方法的线程经过了指定时间
  2. 线程调用的阻塞式IO方法已经返回。
  3. 线程成功地获得了试图取得的同步监视器。
  4. 线程正在等待某个通知时,其他线程发出了一个通知
  5. 处于挂起状态的线程被调用了resumed()恢复方法。

线程状态转换图

图16.4显示了线程状态转换图。
这里有一张图片
从图16.4中可以看出:

  • 线程从阻塞状态只能进入就绪状态,无法直接进入运行状态
  • 就绪状态运行状态之间的转换通常不受程序控制,而是由系统线程调度所决定,
    • 当处于就绪状态的线程获得处理器资源时,该线程进入运行状态;
    • 当处于运行状态的线程失去处理器资源时,该线程进入就绪状态
      • 但有个方法例外,**调用yield()方法可以让运行状态的线程转入就绪状态**。

16.3.3 线程死亡

线程死亡 run方法结束 异常 stop

线程会以如下三种方式结束,结束后就处于死亡状态。

  • run()call()方法执行完成,线程正常结束。
  • 线程抛出一个未捕获的ExceptionError
  • 直接调用该线程的stop()方法来结束该线程,不过stop()方法容易导致死锁,通常不推荐使用。

一个线程结束不会影响其他线程

当主线程结束时,其他线程不受任何影响,并不会随之结束。一旦子线程启动起来后,它就拥有和主线程相同的地位,它不会受主线程的影响。

isAlive方法

为了测试某个线程是否已经死亡,可以调用线程对象的isAlive()方法,

  • 当线程处于就绪运行阻塞三种状态时,isAlive()方法将返回true;
  • 当线程处于新建死亡两种状态时,isAlive()方法将返回false

已经死亡的线程无法再次启动

不要试图对一个已经死亡的线程调用start方法使它重新启动,死亡就是死亡,该线程将不可再次作为线程执行。

程序 死亡的线程无法再次start

下面程序尝试对处于死亡状态的线程再次调用start方法。

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
public class StartDead extends Thread
{
// 重写run方法,run方法的方法体就是线程执行体
private int i ;
public void run()
{
for ( ; i < 100 ; i++ )
{
System.out.println(getName() + " " + i);
}
}
public static void main(String[] args)
{
// 创建线程对象
StartDead sd = new StartDead();
for (int i = 0; i < 300; i++)
{
// 调用Thread的currentThread方法获取当前线程
System.out.println(Thread.currentThread().getName()
+ " " + i);
if (i == 20)
{
// 启动线程
// 判断启动后线程的isAlive()值,输出true
sd.start();
System.out.println(sd.isAlive());
}
// 只有当线程处于新建、死亡两种状态时isAlive()方法返回false。
// 当i > 20,则该线程肯定已经启动过了,
// 如果sd.isAlive()为假时,那只能是死亡状态了。
if (i > 20 && !sd.isAlive())

{
// 试图再次启动该线程
sd.start();
}
}
}
}

上面程序中试图在线程已死亡的情况下再次调用start方法来启动该线程。
运行上面程序,将引发IllegalThreadStateException异常。这表明处于死亡状态的线程无法再次运行了

只能对新建的线程start一次

不要对处于死亡状态的线程调用start方法,只能对新建状态的线程调用start方法,新建状态的线程两次调用start方法也是错误的。这都会引发IllegalThreadStateException异常.

16.2 线程的创建和启动

线程对象都必须是Thread类的实例

Java使用Thread类代表线程,所有的线程对象都必须是Thread类或其子类的实例
每个线程的作用是完成一定的任务,实际上就是执行一段程序流(一段顺序执行的代码)。Java使用线程执行体来代表这段程序流。

16.2.1 继承Thread类创建线程类

继承Thread来创建并启动多线程的步骤

通过继承Thread类来创建并启动多线程的步骤如下:

  1. 定义Thread类的子类,并重写该类的run()方法,该run()方法的方法体就代表了线程需要完成的任务。因此run()方法称为线程执行体
  2. 创建Thread子类的实例,即创建了线程对象
  3. 调用线程对象的start()方法来启动该线程。

程序 继承Thread类实现多线程

下面程序示范了通过继承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
// 1.通过继承Thread类来创建线程类
public class FirstThread extends Thread
{
private int i ;
// 重写run方法,run方法的方法体就是线程执行体
public void run()
{
for ( ; i < 100 ; i++ )
{
// 当线程类继承Thread类时,直接使用this即可获取当前线程
// Thread对象的getName()返回当前该线程的名字
// 因此可以直接调用getName()方法返回当前线程的名
System.out.println(getName() + " " + i);
}
}
public static void main(String[] args)
{
for (int i = 0; i < 100; i++)
{
// 调用Thread的currentThread方法获取当前线程
System.out.println(Thread.currentThread().getName() + " " + i);
if (i == 20)
{
// 2.创建、并启动第一条线程
new FirstThread().start();
// 创建、并启动第二条线程
new FirstThread().start();
}
}
}
}

部分运行结果

1
2
3
4
5
6
7
8
9
10
11
12
Thread-0 0
Thread-1 0
Thread-0 1
main 25
Thread-0 2
Thread-1 1
Thread-0 3
Thread-0 4
main 26
main 27
Thread-0 5
Thread-1 2

上面程序中的FirstThread类继承了Thread类,并实现了run()方法,run()方法里的代码执行流就是该线程所需要完成的任务

主线程

Java程序开始运行后,程序至少会创建一个主线程,main()方法的方法体就是主线程的线程执行体

Thread类常用方法

除此之外,上面程序还用到了线程的如下两个方法。

方法 描述
static Thread currentThread() 返回当前正在执行的线程对象
String getName() 返回调用该方法的线程名字
void setName(String name) 为线程设置名字

在默认情况下,主线程的名字为main,用户启动的多个线程的名字依次为Thread-0Thread-1Thread-2、…、Thread-n等。

继承Thread类创建线程的缺点

使用继承Thread类的方法来创建线程类时,多个线程之间无法共享线程类的实例变量.

16.2.2 实现Runnable接口创建线程类

实现Runnable接口来创建并启动多线程的步骤

  1. 定义Runnable接口的实现类,并重写该接口的run()方法,**该run()方法的方法体同样是该线程的线程执行体**。
  2. 创建Runnable实现类的实例,并以此实例作为Threadtarget来创建Thread对象
    1. Thread对象才是真正的线程对象
    2. 也可以在创建Thread对象时为该Thread对象指定一个名字
  3. 调用线程对象的start()方法来启动该线程。
Thread类构造器 描述
Thread(Runnable target) Allocates a new Thread object.
Thread(Runnable target, String name) Allocates a new Thread object.

程序 实现Runable接口来创建并启动多线程

下面程序示范了通过实现Runnable接口来创建并启动多线程。

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
// 通过实现Runnable接口来创建线程类
public class SecondThread implements Runnable
{
private int i ;
// run方法同样是线程执行体
public void run()
{
for ( ; i < 100 ; i++ )
{
// 当线程类实现Runnable接口时,
// 如果想获取当前线程,只能用Thread.currentThread()方法。
System.out.println(Thread.currentThread().getName() + " " + i);
}
}

public static void main(String[] args)
{
for (int i = 0; i < 100; i++)
{
System.out.println(Thread.currentThread().getName() + " " + i);
if (i == 20)
{
SecondThread st = new SecondThread(); // ①
// 通过new Thread(target , name)方法创建新线程
new Thread(st , "新线程1").start();
new Thread(st , "新线程2").start();
}
}
}
}

上面程序中实现了run()方法,也就是定义了该线程的线程执行体。

如何获取当前线程对象

  • 通过继承Thread类来获得当前线程对象比较简单,直接使用this关键字就可以了;
  • 但通过实现Runnable接口来获得当前线程对象,则必须使用Thread.currentThread()方法。

Runnable接口是函数式接口

Runnable接口中只包含一个抽象方法,从Java 8开始,Runnable接口使用了@FunctionalInterface修饰。也就是说, Runnable接口是函数式接口,可使用Lambda表达式创建Runnable对象。接下来介绍的Callable接口也是函数式接口。

多个线程共享一个target则可以共享target中的实例变量

采用Runnable接口的方式创建的多个线程可以共享线程类的实例变量。这是因为只创建了一个target实例,而多个线程可以共享这个target实例,因而多个线程中的实例变量也是共享的。

部分运行效果

1
2
3
4
5
6
7
8
9
10
新线程2  82
新线程1 81
新线程2 83
新线程2 85
main 54
main 55
新线程2 86
新线程2 87
新线程1 84
新线程2 88

从上面的运行结果可以看出:两个子线程的i变量是连续的,也就是采用Runnable接口的方式创建的多个线程可以共享线程类的实例变量。这是因为在这种方式下,程序所创建的Runnable对象只是线程的target,而多个线程可以共享同一个target,所以多个线程可以共享同一个线程类(实际上应该是线程的target类)的实例变量。

16.2.3 使用Callable和Future创建线程

通过实现Runnable接口创建多线程时, Thread类的作用就是把Runnable实例提供的run()方法包装成线程执行体
那么是否可以直接把任意方法都包装成线程执行体呢?Java目前不行,不过C#可以把任意方法包装成线程执行体,包括有返回值的方法

Callable接口简介

Java 5开始,Java提供了Callable接口,可以把该接口看成Runnable接口的增强版,Callable接口提供了一个call()方法可以作为线程执行体,但call()方法比run()方法功能更强大,具体表现为:

  • call()方法可以有返回值。
  • call()方法可以声明抛出异常。
Callable接口的call方法 描述
V call() Computes a result, or throws an exception if unable to do so.

Callable接口实例不能作为Thread构造方法的参数

Callable接口是Java 5新增的接口,但Callable接口不是Runnable接口的子接口,所以**Callable接口实例不能直接作为Threadtarget**

泛型定义call方法返回值 函数式接口

Callable接口有泛型限制,Callable接口里的泛型形参类型与call()方法返回值类型相同
并且Callable接口是函数式接口,因此可使用Lambda表达式创建Callable对象。

Future接口

FutureTask实现类

Java 5提供了Future接口来代表Callable接口里call()方法的返回值,并为Future接口提供了一个FutureTask实现类,FutureTask即实现了Future接口,又实现了Runnable接口,所以**FutureTask可以作为Thread类的target**。

方法 描述
FutureTask(Callable<V> callable) Creates a FutureTask that will, upon running, execute the given Callable.

Future接口方法

Future接口里定义了如下几个公共方法来控制它关联的Callable任务。

获取call方法的返回值 get方法

方法 描述
V get() 返回Callable任务里call方法的返回值。调用该方法将导致程序阻塞,必须等到子线程结束后才会得到返回值
V get(long timeout, TimeUnit unit) 返回Callable任务里call方法的返回值。该方法让程序最多阻塞timeoutunit指定的时间,如果经过指定时间后Callable任务依然没有返回值,将会抛出TimeoutException异常。

判断方法

方法 描述
boolean cancel(boolean mayInterruptIfRunning) 试图取消该Future里关联的Callable任务。
boolean isCancelled() 如果在Callable任务正常完成前被取消,则返回true
boolean isDone() 如果Callable任务已完成,则返回true

创建并启动有返回值的线程的步骤

创建并启动有返回值的线程的步骤如下。

  1. 创建Callable对象
    1. 先创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有返回值,再创建Callable实现类的对象。
    2. 直接使用Lambda表达式创建Callable对象
  2. 创建FutureTask类对象时传入Callable对象作为构造器参数,FutureTask对象封装了该Callable对象的call()方法的返回值.
  3. 创建Thread类对象时传入FutureTask对象作为构造器参数,然后启动新线程。
  4. 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。

程序 带返回值的线程 Lambda表达式写法

下面程序通过实现Callable接口来实现线程类,并启动该线程。

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
import java.util.concurrent.*;

public class ThirdThread
{
public static void main(String[] args)
{
// 1.先使用Lambda表达式创建Callable<Integer>对象
// 2.使用FutureTask来包装Callable对象
FutureTask<Integer> task = new FutureTask<Integer>((Callable<Integer>)() -> {
int i = 0;
for ( ; i < 100 ; i++ )
{
System.out.println(Thread.currentThread().getName()
+ " 的循环变量i的值:" + i);
}
// call()方法可以有返回值
return i;
});

for (int i = 0 ; i < 100 ; i++)
{
System.out.println(Thread.currentThread().getName()
+ " 的循环变量i的值:" + i);
if (i == 20)
{
// 3.实质还是以Callable对象来创建、并启动线程
new Thread(task , "有返回值的线程").start();
}
}
try
{
// 4.获取线程返回值
System.out.println("子线程的返回值:" + task.get());
}
catch (Exception ex)
{
ex.printStackTrace();
}
}
}

上面程序中使用Lambda表达式直接创建了Callable对象,这样就无须先创建Callable实现类,再创建Callable对象了

Callable和Runnable的区别

实现Callable接口与实现Runnable接口并没有太大的差别,只是Callablecall()方法允许声明抛出异常,而且允许带返回值。

  • 上面程序先使用Lambda表达式创建一个Callable接口实例,
  • 然后将Callable接口实例包装成一个FutureTask对象。
  • 主线程中当循环变量i等于20时,程序启动以FutureTask对象为target的线程。
  • 程序最后调用FutureTask对象的get()方法来返回call()方法的返回值,get()方法将导致主线程被阻塞,直到call()方法结束并返回为止

运行上面程序,将看到主线程和call()方法所代表的线程交替执行的情形,程序最后还会输出call()方法的返回值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
main 的循环变量i的值:0
......
main 的循环变量i的值:23
有返回值的线程 的循环变量i的值:0
main 的循环变量i的值:24
有返回值的线程 的循环变量i的值:1
...
main 的循环变量i的值:98
有返回值的线程 的循环变量i的值:72
main 的循环变量i的值:99
有返回值的线程 的循环变量i的值:73
...
有返回值的线程 的循环变量i的值:99
子线程的返回值:100

程序 带返回值的线程 经典写法

G:\dev2\idea_workspace\MyJavaTools\RunableTools\src\demo\thread\CallableThread.java
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
package demo.thread;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class CallableThread implements Callable<String> {
@Override
public String call() throws Exception {
Thread.sleep(3*1000);
return "HelloWorld";
}

public static void main(String[] args) {
CallableThread callable = new CallableThread();
FutureTask<String> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
System.out.println("启动线程");
// 启动线程
thread.start();

// 获取线程的返回值
try {
System.out.println("线程返回值:"+futureTask.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
System.out.println("main end");
}
}

运行结果:

1
2
3
启动线程
线程返回值:HelloWorld
main end

打印“启动线程”线程后,主线程阻塞3秒,
然后输出“线程返回值:HelloWorld”
最后输出“main end”

16.2.4 创建线程的三种方式对比

通过继承Thread类或实现RunnableCallable接口都可以实现多线程,不过实现Runnable接口与实现Callable接口的方式基本相同,只是Callable接口里定义的方法有返回值,可以声明抛出异常而已。因此可以将实现Runnable接口和实现Callable接口归为一种方式。这种方式与继承Thread方式之间的主要差别如下:

实现Runnable,Callable接口方式创建多线程的优缺点

优点

  • 线程类只是实现了Runnable接口或Callable接口,还可以继承其他类
  • 在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。

缺点

  • 编程稍稍复杂,如果需要访问当前线程,则必须使用Thread.currentThread()方法.

继承Thread类的方式创建多线程的优缺点

优点

  • 编写简单,如果需要访问当前线程,则无须使用Thread.currentThread()方法,直接使用this关键字即可获得当前线程。

缺点

  • 因为线程类已经继承了Thread类,所以不能再继承其他父类。

总结

一般推荐采用实现Runnable接口、Callable接口的方式来创建多线程