版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/Never_Blue/article/details/70670280
6、多线程的陷阱
Java语言提供了非常优秀的多线程支持,使得开发者能以简单的代码来创建、启动多线程,而且Java语言内置了多线程支持极好地简化了多线程编程。虽然如此,Java多线程编程中依然存在一些容易混淆的陷阱。
6、1 不要调用run()方法
从Java5开始,Java提供了三种方式来创建、启动多线程。
- 继承Thread类创建线程类,重写run()方法作为线程执行体。
- 实现Runnable接口创建线程类,重写run()方法作为线程执行体。
- 实现Callable接口创建线程类,重写call()方法作为线程执行体。
- 线程类继承了Thread类,无法再继承其他父类。
- 因为每条线程都是Thread子类的实例,因此可以将多条线程的执行流代码与业务数据分离。
public class InvokeRun extends Thread {
private int i;
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 ++ ) {
System.out.println(Thread.currentThread().getName() + " " + i);
if ( i == 20 ) {
//直接调用线程的run()方法,系统会把线程对象当成普通对象,把run方法当成普通方法
new InvokeRun().run();
new InvokeRun().run();
}
}
}
}
程序执行的大致过程如下所示。
- 输出main20之后,又重新开始输出main0.
- 从main0一直输出到main99,再次从main0开始输出。
- 从main0一直输出到main99,再次从main21开始输出,直到main99结束。
如果程序中从未调用对象的start()方法来启动它,那么这个线程对象将一直处于“新建”状态,永远不会作为线程获得执行的机会,只是一个普通的Java对象。当程序调用线程对象的run()方法时,与调用普通Java对象的普通方法并无任何区别,因此绝不会启动一条新线程。
6、2 静态的同步方法
Java提供了synchronized关键字用于修饰方法,使用
synchronized修饰的方法被称为同步方法。当然,synchronized关键字除了修饰方法之外,还可以修饰普通代码块,使用synchronized修饰的代码块被称为同步代码块。
Java语法规定:任何线程进入同步方法、同步代码块之前,必须先获取同步方法、同步代码块对应的同步监视器。对于同步代码块而言,程序必须显示地为它指定同步监视器;对于同步非静态方法而言,该方法的同步监视器是this,即调用该方法的Java对象;对于静态的同步方法而言,该方法的同步监视器不是this,而是该类本身。
public class SynchronizedStatic implements Runnable {
static boolean staticFlag = true;
public static synchronized void test0() {
for ( int i = 0 ; i < 100 ; i ++ ) {
System.out.println("test0:" + Thread.currentThread().getName() + " " + i);
}
}
public void test1() {
synchronized (this) {
for ( int i = 0 ; i < 100 ; i ++ ) {
System.out.println("test1:" + Thread.currentThread().getName() + " " + i);
}
}
}
@Override
public void run() {
if (staticFlag) {
staticFlag = false;
test0();
}
else {
staticFlag = true;
test1();
}
}
public static void main(String[] args) throws Exception {
SynchronizedStatic ss = new SynchronizedStatic();
new Thread(ss).start();
Thread.sleep(10);
new Thread(ss).start();
}
}
上面程序中提供了一个静态同步方法和一个同步代码块。同步代码块使用this作为同步监视,即这两个同步程序单元并没有使用相同的同步监视器,因此
可以同时并发执行,相互之间不会有任何影响。S
ynchronizedStatic类通过staticFlag来控制线程使用哪个方法作为线程执行体。程序第一次执行SynchronizedStatic对象作为target的线程时,staticFlag初始值为true,因此程序将以test0()方法作为线程执行体,而且会把staticFlag修改为false;这使得第二次执行SynchronizedStatic对象作为target的线程时,程序将以test1()方法作为线程执行体。
静态同步方法可以和以this为同步监视器的代码块同时执行,当第一条线程(以test0()方法作为线程执行体的线程)进入同步代码块执行以后,该线程获得了对同步监视器(SynchronizedStatic类)的锁定;第二条线程(以test1()方法作为线程执行体的线程)尝试进入同步代码块执行,进入同步代码块之前,该线程必须获得对this引用(也就是ss变量所引用的对象)的锁定。
public void test1() {
synchronized (SynchronizedStatic.class) {
for ( int i = 0 ; i < 100 ; i ++ ) {
System.out.println("test1:" + Thread.currentThread().getName() + " " + i);
}
}
}
将test1()方法改为上面的形式之后,该同步代码块的同步监视器也是
S
ynchronizedStatic类,也就是与同步静态方法test0()具有相同的同步监视器。在上述代码中,静态同步方法和以当前类为同步监视器的同步代码块不能同时执行,当第一条线程(以test0()方法作为线程执行体的线程)进入同步代码块执行以后,该线程获得了对同步监视器(SynchronizedStatic类)的锁定;第二条线程(以test1()方法作为线程执行体的线程)尝试进入同步代码块执行,进入同步代码块之前,该线程必须获得对SynchronizedStatic类的锁定。因为第一条线程已经锁定了SynchronizedStatic类,在第一条线程执行结束之前,它不会释放SynchronizedStatic类的锁定,因此只有等第一条线程执行结束后才可以切换到执行第二条线程。
6、3 静态初始化块启动新线程执行初始化
public class StaticThreadInit {
static {
Thread t = new Thread() {
public void run() {
System.out.println("进入run方法");
System.out.println(website);
website = "www.sohu.com";
System.out.println("退出run方法");
}
};
t.start();
try {
t.join();
}
catch (Exception e) {
e.printStackTrace();
}
}
static String website = "www.baidu.com";
public static void main(String[] args) {
System.out.println(StaticThreadInit.website);
}
}
输出结果为:
进入run方法
上面程序定义了一个静态的website,并为其指定了初试值,但程序也在静态初始化块中也为website进行赋值,且静态初始化块排在前面。如果只是保留这样的程序结果,那么程序的结果将会非常的清晰:静态初始化先将为website赋值为www.sohu.com,然后初始化机制再将website赋值为www.baidu.com。但是程序中的静态初始化块是启动一条新的线程来执行初始化块操作,就导致最终只输出“
进入run方法”,不会继续向下执行。
下面详细分析该程序的执行细节。程序总是先从main方法开始执行,main方法只有一行代码,访问StaticThreadInit类的website静态field的值。当某条线程试图访问一个类的静态field时,根据该类的状态可能出现如下四种情况。
- 该类尚未被初始化:当前线程开始对其执行初始化。
- 该类正在被当前线程执行初始化:这是对初始化的递归请求。
- 该类正在被其他线程执行初始化:当前线程暂停,等待其他线程初始化完成。
- 这个类已经被初始化:直接得到该静态field的值。
- 为该类的所有静态field分配内存。
- 调用静态初始化块的代码执行初始化。
新线程开始执行之后,首先执行System.out.println("进入run方法");代码,接着,程序试图执行System.out.println(website);,此时问题就出现了:StaticThreadInit类正由main线程执行初始化,因此新线程会等待main线程对StaticThreadInit类执行初始化结束。这时候满足了死锁条件:两条线程互相等待对方执行,因此都不能向下执行。因此程序执行到此处就出现了死锁,程序没法向下执行,也就是运行该程序所看到的结果。
经过上面的分析可以看出,上面程序出现死锁的关键在于程序调用的t.join(),这导致了main线程必须等待新线程执行结束才能向下执行。下面将t.join()方法去掉,将静态代码块部分改为如下所示。
static {
Thread t = new Thread() {
public void run() {
System.out.println("进入run方法");
System.out.println(website);
website = "www.sohu.com";
System.out.println("退出run方法");
}
};
t.start();
}
输出结果为:
www.baidu.com
进入run方法
www.baidu.com
退出run方法
进入run方法
www.baidu.com
退出run方法
两次访问website的值都是www.baidu.com。再来分析一下运行的过程,main线程进入StaticThreadInit静态初始化块之后,同样也是创建并启动了新线程,由于此时并未调用新线程的join()方法,因此新线程出于就绪状态,还未进入到运行状态。main线程继续执行初始化操作,它会将website的值初始化为www.baidu.com,至此StaticThreadInit类初始化完成。程序也就会输出www.baidu.com。接下来新线程才进入运行状态,依次执行run()方法里的每行代码,此时访问道德website的值依然是www.baidu.com,run方法()最后将website的值改为www.sohu.com,但程序已经不再访问它了。
产生上面运行结果的原因是调用一条线程的start()方法后,该线程并不会立即进入运行状态,它只是保持在就绪状态。为了改变这种状态,再次将StaticThreadInit类的静态初始化块代码,如下所示。
static {
Thread t = new Thread() {
public void run() {
System.out.println("进入run方法");
System.out.println(website);
website = "www.sohu.com";
System.out.println("退出run方法");
}
};
t.start();
try {
Thread.sleep(1);
}
catch (Exception e) {
e.printStackTrace();
}
}
输出结果为:
进入run方法
www.baidu.com
www.baidu.com
退出run方法
www.baidu.com
www.baidu.com
退出run方法
上面程序调用新线程的start()方法启动新线程后,立即调用Thread.sleep(1)暂停当前线程,使得新线程立即获得执行机会。即使让新线程立即启动,新线程为website指定的值依然没有起作用。这依然和类初始化机制有关。当main线程进入StaticThreadInit类的静态初始化块后,main线程创建、启动一天新线程,然后主线程调用Thread.sleep(1)暂停自己,是的新线程获得执行机会,于是看到了运行结果的第一行输出“进入run方法”。然后,新线程视图执行System.out.println(website);来输出website的值,但由于StaticThreadInit类还未初始化完成,因此新线程不得不放弃执行。线程调度器再次切换到main线程,于是main线程将website初始化为www.baidu.com,至此StaticThreadInit类初始化完成。
通常main线程不会立刻切换回来执行新线程,它会执行main方法里的第一行代码,也就是输出website的值,于是看到输出结果第二行“www.baidu.com”。main线程执行完后,系统切换回来执行新线程,新线程访问website时也会输出www.baidu.com,也就是输出结果第三行。run方法()最后将website的值改为www.sohu.com,但程序已经不再访问它了。
实际上有一个问题:静态初始化块里启动新线程对静态field赋值根本不是初始化,它只是一次普通的赋值。
public class StaticThreadInit2 {
static {
Thread t = new Thread() {
public void run() {
website = "www.sohu.com"; //报错:The final field StaticThreadInit2.website cannot be assigned
}
};
t.start();
}
final static String website; //报错:The blank final field website may not have been initialized
public static void main(String[] args) {
System.out.println(StaticThreadInit2.website);
}
}
上面程序定义了一个final静态变量website,没有为它指定初始值,接着试图在静态初始化块中为website指定初始值。在正常情况下,这个程序没有任何问题,不过当静态初始化块启动了一条新线程为website指定初始值时就会有问题。从上面的错误提示可以看出,静态初始化块启动的新线程根本不允许为website赋值。这表明,新线程为website赋值根本不是初始化操作,只是一次普通的赋值。
总结:不要认为所有放在静态初始化块中的代码就一定是类初始化操作,静态初始化块中启动新线程的run()方法代码只是新线程的线程执行体,并不是类初始化操作。类似地,不要认为所有放在非静态初始化块中的代码就一定是对象初始化操作,非静态初始化快中启动的新线程的run()方法代码只是新线程的线程执行体,并不是对象初始化操作。
6、4 多线程执行环境
在不考虑多线程环境的情况下,很多代码都是完全正确的。但一旦将它们放在多线程环境下,这个类就变得非常的容易出错,这种类被称为线程不安全类。在多线程环境下使用线程不安全的类总是危险的,多线程环境下应该使用线程安全的类。public class Account {
private String accountNo;
private double balance; //账户余额
public Account() {}
public Account(String accountNo , double balance) {
this.accountNo = accountNo;
this.balance = balance;
}
public double getBalance() {
return this.balance;
}
public void draw(double drawAmount) {
if ( balance >= drawAmount ) {
System.out.println(Thread.currentThread().getName() + "取钱成功!吐出钞票:" + drawAmount);
balance -= drawAmount;
System.out.println("\t余额为:" + balance);
}
else {
System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足!");
}
}
public int hashCode() {
return accountNo.hashCode();
}
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (obj.getClass() == Account.class) {
Account target = (Account)obj;
return accountNo.equals(target.accountNo);
}
return false;
}
}
上面程序定义了Account类,该类代表一个银行账户,实现了一个draw()方法用于取钱。这个取钱过程从逻辑上来说没有任何问题:系统先判断账户余额是否大于取款金额,当账户余额大于取款金额时,取钱成功;否则,系统提示余额不足。但是由于它只是一个线程不安全的类,因此Account类不适用于多线程的环境。
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(drawAmount);
}
}
public class DrawTest {
public static void main(String[] args) {
Account acc = new Account("123456" , 1000);
new DrawThread("甲", acc, 800).start();
new DrawThread("乙", acc, 800).start();
}
}
可能出现的结果为:
乙取钱成功!吐出钞票:800.0
余额为:200.0
甲取钱成功!吐出钞票:800.0
余额为:-600.0
余额为:200.0
甲取钱成功!吐出钞票:800.0
余额为:-600.0
从结果来看这个程序出现了问题,该账户余额只有1000元,但这两条线程各自取走了800元,这就是有Account类线程不安全导致的。为了将Account类能更好地适用于多线程的环境,可以将Account类修改为线程安全的形式。线程安全的类具有如下特征。
- 该类的对象可以被多个线程安全地访问。
- 每个线程调用该对象的任意方法之后都将得到正确结果。
- 每个线程调用该对象的任意方法之后,该对象状态依然保持合理状态。
public class Account {
private String accountNo;
private double balance; //账户余额
public Account() {}
public Account(String accountNo , double balance) {
this.accountNo = accountNo;
this.balance = balance;
}
public synchronized double getBalance() { //使用synchronized关键字修饰
return this.balance;
}
public synchronized void draw(double drawAmount) { //使用synchronized关键字修饰
if ( balance >= drawAmount ) {
System.out.println(Thread.currentThread().getName() + "取钱成功!吐出钞票:" + drawAmount);
balance -= drawAmount;
System.out.println("\t余额为:" + balance);
}
else {
System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足!");
}
}
public int hashCode() {
return accountNo.hashCode();
}
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (obj.getClass() == Account.class) {
Account target = (Account)obj;
return accountNo.equals(target.accountNo);
}
return false;
}
}
将Account类中的getBalance()方法和draw(double drawAmount)方法使用synchronized关键字修饰,使得可以访问共享资源balance,因此使得Account类成为一个线程安全的类。