1、线程安全问题
关于线程安全问题,有一个经典案例:银行取钱问题。
假设有一个账户,有两个线程从账户里取钱,如果余额大于取钱金额,则取钱成功,反之则失败。
下面来看下线程不安全的程序会出什么问题。
账户类:
1 public class Account { 2 3 public int balance = 10;//账户余额 4 5 6 7 //取钱的方法 8 9 public void draw(int money){ 10 11 if (balance >= money) { 12 13 //此处让线程Thread-1睡眠1秒,是为了模拟线程不安全造成的错误 14 15 if ("Thread-1".equals(Thread.currentThread().getName())) { 16 17 try { 18 19 Thread.sleep(1000); 20 21 } catch (InterruptedException e) { 22 23 e.printStackTrace(); 24 25 } 26 27 } 28 29 30 31 balance = balance - money; 32 33 System.out.println(Thread.currentThread().getName()+"取钱成功,余额:"+balance); 34 35 }else{ 36 37 System.out.println("取钱失败,余额不足。余额:"+balance); 38 39 } 40 41 } 42 43 }
取钱线程:
1 public class DrawThread implements Runnable{ 2 3 public Account account; 4 5 public DrawThread(Account account){ 6 7 this.account = account; 8 9 } 10 11 @Override 12 13 public void run() { 14 15 //写个死循环,模拟不停取钱 16 17 while(true){ 18 19 try { 20 21 //此处睡眠500毫秒是为了让程序运行的慢一点,方便观察 22 23 Thread.sleep(500); 24 25 } catch (InterruptedException e) { 26 27 e.printStackTrace(); 28 29 } 30 31 //调用取钱方法,一次取4元 32 33 account.draw(4); 34 35 } 36 37 } 38 39 }
测试类:
1 public class TestDraw { 2 3 public static void main(String[] args) { 4 5 //创建一个账户 6 7 Account account = new Account(); 8 9 //创建两个线程,从同一个账户取钱 10 11 DrawThread dtOne = new DrawThread(account); 12 13 DrawThread dtTwo = new DrawThread(account); 14 15 //启动线程 16 17 new Thread(dtOne).start(); 18 19 new Thread(dtTwo).start(); 20 21 } 22 23 }
测试结果:
1 Thread-0取钱成功,余额:6 2 3 Thread-0取钱成功,余额:2 4 5 取钱失败,余额不足。余额:2 6 7 Thread-1取钱成功,余额:-2 8 9 取钱失败,余额不足。余额:-2 10 11 取钱失败,余额不足。余额:-2
这个结果显然是不对的,当余额小于取钱金额时,程序应该取钱失败,而不是把余额变成负数。之所以会出现这种情况,是因为当线程Thread-1通过balance >= money之后被阻塞了,这时候线程Thread-0也通过了balance >= money判断,并且把钱取走了。这之后,Thread-1重新开始运行,继续取钱,于是余额就变成负数了。
在实际的开发中,由于线程调度不可控,也可能出现类似的情况,所以对多线程操作一定要注意线程安全。
2、线程同步
为了解决线程安全问题,有三种方法:同步代码块、同步方法、同步锁。
同步代码块:
同步代码块的语法为:
synchronized (obj) {
…
//此处代码就是同步代码块
}
以上代码的obj叫做同步监视器,以上代码的含义是,线程开始执行同步代码块之前,必须获得对同步监视器的锁定。一般来说,我们把并发时共享的资源作为同步监视器,例子中账户就是共享的资源,所以写this,表示对象本身。
使用同步代码块改造的账户类如下:
1 //取钱的方法 2 3 public void draw(int money){ 4 5 //同步代码块开始 6 7 synchronized (this) { 8 9 if (balance >= money) { 10 11 //此处让线程Thread-1睡眠1秒,是为了模拟线程不安全造成的错误 12 13 if ("Thread-1".equals(Thread.currentThread().getName())) { 14 15 try { 16 17 Thread.sleep(1000); 18 19 } catch (InterruptedException e) { 20 21 e.printStackTrace(); 22 23 } 24 25 } 26 27 28 29 balance = balance - money; 30 31 System.out.println(Thread.currentThread().getName()+"取钱成功,余额:"+balance); 32 33 }else{ 34 35 System.out.println("取钱失败,余额不足。余额:"+balance); 36 37 } 38 39 } 40 41 //同步代码块结束 42 43 }
同步方法:
同步方法即使用synchronized修饰方法,不用显示指定同步监视器,其同步监视器就是this,即对象本身。
使用同步方法改造的账户类如下:
1 //取钱的方法 2 3 public synchronized void draw(int money){ 4 5 if (balance >= money) { 6 7 //此处让线程Thread-1睡眠1秒,是为了模拟线程不安全造成的错误 8 9 if ("Thread-1".equals(Thread.currentThread().getName())) { 10 11 try { 12 13 Thread.sleep(1000); 14 15 } catch (InterruptedException e) { 16 17 e.printStackTrace(); 18 19 } 20 21 } 22 23 24 25 balance = balance - money; 26 27 System.out.println(Thread.currentThread().getName()+"取钱成功,余额:"+balance); 28 29 }else{ 30 31 System.out.println("取钱失败,余额不足。余额:"+balance); 32 33 } 34 35 }
需要注意的是,synchronized不可以修饰属性和构造方法。
释放同步监视器的锁定
以下情况将释放对同步监视器的锁定:
- 同步方法(代码块)执行完毕。
- 执行中遇到return、break终止了同步方法(代码块)的执行。
- 同步方法(代码块)抛出了未处理的异常或错误。
- 调用了同步方法(代码块)的wait()方法,此时当前线程暂停,并释放对同步监视器的锁定。
以下情况不会释放对同步监视器的锁定:
- 调用sleep、yield方法,当前线程会暂停,但不会释放锁定。
- 其他线程调用了该线程的suspend方法将该线程挂起,该线程不会释放对同步监视器的锁定。注意,尽量不要使用suspend和resume方法,容易死锁。
同步锁
从JDK1.5开始,可以通过显示定义同步锁来实现线程安全。
使用方法和synchronized大同小异,基本上也是加锁—执行代码—解锁这么一个过程。
使用Lock改造的取钱方法如下:
1 //定义锁对象 2 3 private final Lock lock = new ReentrantLock(); 4 5 //取钱的方法 6 7 public void draw(int money){ 8 9 //加锁 10 11 lock.lock(); 12 13 try { 14 15 if (balance >= money) { 16 17 //此处让线程Thread-1睡眠1秒,是为了模拟线程不安全造成的错误 18 19 if ("Thread-1".equals(Thread.currentThread().getName())) { 20 21 try { 22 23 Thread.sleep(1000); 24 25 } catch (InterruptedException e) { 26 27 e.printStackTrace(); 28 29 } 30 31 } 32 33 34 35 balance = balance - money; 36 37 System.out.println(Thread.currentThread().getName()+"取钱成功,余额:"+balance); 38 39 }else{ 40 41 System.out.println("取钱失败,余额不足。余额:"+balance); 42 43 } 44 45 } finally { 46 47 //为了确保解锁,放在finally里 48 49 lock.unlock(); 50 51 } 52 53 }
以上代码中,为了确保最后能释放锁,所以把解锁代码放在finally中。
和synchronized相比,Lock在使用上更灵活。上例中使用的是可重入锁,即线程可以对已加锁的代码再加锁。此外还有读写锁等。
3、死锁
两个线程相互等待对方释放对同步监视器的锁定,这种情况叫死锁。