多线程编程很容易突然出现“错误情况”,这是由系统的线程调度具有一定随机性造成的,不过即使程序偶然出现问题,那也是由于编程不当引起的。当使用多个线程 来访问数据时,很容易“偶然”出现线程安全问题。
一个线程安全问题——银行取钱问题
实现功能:
-系统判断账户余额是否大于取款金额,如果大于取款金额,则取款成功,否则取款失败。
-模拟两个人使用同一个账户并发取钱。
账户类代码:
public class Account {
//封装账户编号,账户余额的两个成员变量
private String accountNo;
private double balance;
//构造器
public Account(String accountNo, double balance){
this.accountNo = accountNo;
this.balance = balance;
}
//set,get方法
public String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
public double getBalance() {
return balance;
}
public void setBalance(double balance) {
this.balance = balance;
}
}
取钱的线程类:
public class DrawThread extends Thread{
//模拟账户用户
private Account account;
//当前取现线程所希望取的钱数
private double drawAmout;
public DrawThread(String name, Account account, double drawAmout){
super(name);
this.account = account;
this.drawAmout = drawAmout;
}
//当多个线程修改同一个共享数据时,将涉及数据安全问题
public void run(){
//账户余额大于取钱数目
if(account.getBalance() >= drawAmout){
//吐出钞票
System.out.println(getName() + "取钱成功,取钱:" + drawAmout);
try{
Thread.sleep(1);
}catch (InterruptedException ex){
ex.printStackTrace();
}
//修改余额
account.setBalance(account.getBalance() - drawAmout);
System.out.println("\t 余额为:" + account.getBalance());
} else {
System.out.println(getName() + "取钱失败,余额不足");
}
}
}
启动:
public class DrawTest {
public static void main(String[] args) {
//创建一个账户
Account acct = new Account("123456", 1000);
//模拟两个线程对同一个账户取钱
new DrawThread("甲", acct, 800).start();
new DrawThread("乙", acct, 800).start();
}
}
代码结果:
甲取钱成功,取钱:800.0
乙取钱成功,取钱:800.0
余额为:200.0
余额为:-600.0
这里可以看出线程出现了错误,余额仅有1000却能取出1600,并且余额为负值。出现这种错误是因为run()方法的方法体不具有同步安全性——程序中有两个并发线程在修改Account对象。
为了解决这个问题,java的多线程支持引入了同步监视器来解决这个问题。
同步代码块
使用同步监视器的通用方法就是同步代码块。同步代码块的语法格式:
synchronized(obj){
...
//此处的代码就是同步代码块
}
在上面语法中,括号内的obj就是同步监视器,线程开始执行同步代码块之前,必须先获得对同步监视器的锁定。
需要注意的一点是,任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完成后,该线程会释放对该同步监视器的锁定。
同步监视器最大目的:阻止两个线程对同一个共享资源进行并发访问,因此通常把可能被并发访问的共享资源充当同步监视器。在这里我选择account作为同步监视器,修改DrawThread的代码:
public class DrawThread extends Thread {
//模拟用户账户
private Account account;
//模拟取钱线程所希望取钱数
private double drawAmout;
public NewDrawThread(String name, Account account, double drawAmout){
super(name);
this.account = account;
this.drawAmout = drawAmout;
}
//当多个线程修改同一个共享数据时,将涉及数据安全问题
public void run(){
//使用account作为同步监视器,任何线程进入下面同步代码之前,必须先获得account账户的锁定
//其他线程无法获得所,也就无法修改它
//这种做法符合:加锁->修改->释放锁 的逻辑
synchronized (account) {
//账户余额大于取钱数目
if(account.getBalance() >= drawAmout){
//吐出钞票
System.out.println(getName() + "取钱成功,取钱:" + drawAmout);
try{
Thread.sleep(1);
}catch (InterruptedException ex){
ex.printStackTrace();
}
//修改余额
account.setBalance(account.getBalance() - drawAmout);
System.out.println("\t 余额为:" + account.getBalance());
} else {
System.out.println(getName() + "取钱失败,余额不足");
}
}
//同步代码块结束,该线程释放同步锁
}
}
运行结果:
甲取钱成功,取钱:800.0
余额为:200.0
乙取钱失败,余额不足
同步方法
与同步代码块对应,Java的多线程安全支持还提供了同步方法,同步方法就是使用synchronized关键字来修饰的方法。对于synchronized修饰的实例方法(非static方法)而言,无须显式指定同步监视器,同步方法的监视器就是this,也就是调用该方法的对象。
对于同步方法,选择修改Account类,使其成为线程安全的类。
public class Account {
//封装账户编号,账户余额的两个成员变量
private String accountNo;
private double balance;
//构造器
public Account(String accountNo, double balance){
this.accountNo = accountNo;
this.balance = balance;
}
//AccountNo set,get方法
public String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
//因为账户余额不允许随便修改,所以只为balance提供getter方法
public double getBalance() {
return 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() + "取钱失败,余额不足");
}
}
}
上面程序中增加了一个代表取钱的draw()方法,并使用了synchronized关键字修饰该方法,把该方法变成同步方法,该同步方法的同步监视器是this,因此对于同一个Account账户而言,任意时刻只能有一个线程获得对Account对象的锁定,然后进入draw()方法执行取钱操作。
因为Account类中提供了draw()方法,而且取消了setBalance()方法,所以需要改写DrawThread线程类,该线程类的run()方法只要调用Account对象的draw()方法即可执行取钱操作。run()方法代码片段如下。
public void run(){
//直接调用account对象的draw()方法来执行取钱操作
//同步方法的同步监视器是this,this代表调用draw方法对象
//线程进入draw()方法之前,必须先对account对象加锁
account.draw(drawAmout);
}
此外synchronized关键字可以修饰方法,可以修饰代码块,但不能修饰构造器、成员变量。
既然获得对同步监视器的锁定,那么什么时候会释放同步监视器的锁定呢?
–当前线程的同步代码块、同步方法执行结束,当前线程即释放同步监视器。
–当前线程在同步代码块、同步方法中遇到break、rerurn终止了该代码块、该方法的继续执行,当前线程即释放同步监视器。
–当前线程在同步代码块、同步方法中出现了未处理的Error或Exception,导致了该代码块、该方法异常结束时,当前线程即释放同步监视器。
–当前线程执行同步代码块或同步方法时,程序执行了同步监视器对象的wait()方法,则当前线程暂停,并释放同步监视器。
以下是线程不会释放同步监视器的情况:
–线程执行同步代码块或同步方法时,程序调用了Thread.sleep()、Thread.yield()方法来暂停当前线程的执行,当前线程不会释放同步监视器。
–线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器。
同步锁(Lock)
从Java5开始,Java提供了一种功能更加强大的线程同步机制——通过显示定义同步锁对象来实现同步,在这种机制下,同步锁由Lock对象充当。
Lock提供了比synchronized方法和synchronized代码块更加广泛的锁定操作,Lock允许实现更灵活的结构,可以具有差别很大的属性没并且支持多个相关的Condition对象。
Lock是控制多个线程对共享资源进行访问的工具。通常锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。
在实现线程安全的控制中比较常用的是ReentrantLock(可重入锁)。使用该Lock对象可以显式地加锁、释放锁,通常使用ReentrantLock的代码如下:
class X{
//定义锁对象
private final ReentrantLock lock = new ReentrantLock();
...
//定义需要保证线程安全的方法
public void m(){
//加锁
lock.lock();
try{
//需要保证线程安全的代码
} finally{
lock.unlock();
}
}
}
对Account类使用同步锁:
public class Account {
//定义锁对象
private final ReentrantLock lock = new ReentrantLock();
//封装账户编号,账户余额的两个成员变量
private String accountNo;
private double balance;
//构造器
public Account(String accountNo, double balance){
this.accountNo = accountNo;
this.balance = balance;
}
//AccountNo set,get方法
public String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
//因为账户余额不允许随便修改,所以只为balance提供getter方法
public double getBalance() {
return balance;
}
//提供一个线程安全的draw()方法来完成取钱操作
public void draw(double drawAmount){
//加锁
lock.lock();
try{
//账户余额大于取钱数目
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() + "取钱失败,余额不足");
}
}finally{
//修改完成,释放锁
lock.unlock();
}
}
}
ReentrantLock锁具有可重入性,也就是说,一个线程可以对已被加锁的ReentrantLock锁再次加锁,ReentrantLock对象会维持一个计数器来追踪lock()方法的嵌套调用,线程在每次调用lock()加锁后,必须显示调用unlock()来释放锁,所以一段被锁保护的代码可以调用另一个被相同锁保护的方法。
死锁
当两个线程相互等待对方释放同步监视器时就会发生死锁,Java虚拟机没有监测,也没有采取措施来处理死锁情况,所以多线程编程时应该采取措施避免死锁出现。一旦出现死锁,整个程序既不会发生任何异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。
死锁是很容易发生的,尤其在系统中出现多个同步监视器的情况下。
出现死锁的代码:
class A{
public synchronized void foo( B b ){
System.out.println("当前线程名:" + Thread.currentThread().getName() +
"进入了A实例的foo()方法");
try{
Thread.sleep(200);
}catch (InterruptedException ex){
ex.printStackTrace();
}
System.out.println("当前线程名:" + Thread.currentThread().getName() +
"企图调用B实例的last()方法");
b.last();
}
public synchronized void last(){
System.out.println("进入了A类的last()方法内部");
}
}
class B{
public synchronized void bar( A a ){
System.out.println("当前线程名:" + Thread.currentThread().getName() +
"进入了B实例的bar()方法");
try{
Thread.sleep(200);
}catch (InterruptedException ex){
ex.printStackTrace();
}
System.out.println("当前线程名:" + Thread.currentThread().getName() +
"企图调用A实例的last()方法");
a.last();
}
public synchronized void last(){
System.out.println("进入了B类的last()方法内部");
}
}
public class DeadLock implements Runnable{
A a = new A();
B b = new B();
public void init(){
Thread.currentThread().setName("主线程");
//调用a对象的foo()方法
a.foo(b);
System.out.println("进入了主线程之后");
}
public void run(){
Thread.currentThread().setName("副线程");
//调用b对象的bar()方法
b.bar(a);
System.out.println("进入了副线程之后");
}
public static void main(String[] args){
DeadLock dl = new DeadLock();
//以dl为target启动新线程
new Thread(dl).start();
//调用init()方法
dl.init();
}
}
代码结果:
当前线程名:副线程进入了B实例的bar()方法
当前线程名:主线程进入了A实例的foo()方法
当前线程名:主线程企图调用B实例的last()方法
当前线程名:副线程企图调用A实例的last()方法
上面代码出现了主线程保持着A对象德尔锁,等待对B对象加锁,而副线程保持着B对象的锁,等待对A对象加锁,两个线程互相等待对方先释放,所以出现了死锁。