Java多线程之线程的同步
实际开发中我们也经常提到说线程安全问题,那么什么是线程安全问题呢?
线程不安全就是说在多线程编程中出现了错误情况,由于系统的线程调度具有一定的随机性,当使用多个线程来访问同一个数据时,非常容易出现线程安全问题。具体原因如下:
1,多个线程同时访问一个数据资源(该资源称为临界资源),形成数据发生不一致和不完整。
2,数据的不一致往往是因为一个线程中的多个关联的操作(这几个操作合成原子操作)未全部完成。
关于线程安全问题,有一个经典的情景:银行取钱。代码如下:
/**
*
* @version 1L
* @author LinkinPark
* @since 2015-2-4
* @motto 梦似烟花心似水,同学少年不言情
* @desc ^ 账户类,封装账户编号、账户余额两个Field
*/
public class Account
{
private String accountNo;//账户编号
private double balance;//账户余额 public Account()
{
} public Account(String accountNo, double balance)
{
this.accountNo = accountNo;
this.balance = balance;
} public void setAccountNo(String accountNo)
{
this.accountNo = accountNo;
} public String getAccountNo()
{
return this.accountNo;
} public void setBalance(double balance)
{
this.balance = balance;
} public double getBalance()
{
return this.balance;
} 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;
} }
/**
*
* @version 1L
* @author LinkinPark
* @since 2015-2-4
* @motto 梦似烟花心似水,同学少年不言情
* @desc ^ 取钱的线程类
*/
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;
} // 当多条线程修改同一个共享数据时,将涉及数据安全问题。比如说:2条线程进来取钱了,一条取完钱了但是还没有修改余额,那么另外一条就又进来了,本来是钱不够了,但是他还以为钱够,所以逻辑上就有错了
public void run()
{
// 账户余额大于取钱数目
if (account.getBalance() >= drawAmount)
{
System.out.println(getName() + "取钱成功!取出的钱是:" + drawAmount);
try
{
Thread.sleep(1000);
}
catch (InterruptedException ex)
{
ex.printStackTrace();
}
// 修改余额
account.setBalance(account.getBalance() - drawAmount);
System.out.println("\t余额为: " + account.getBalance());
}
else
{
System.out.println(getName() + "取钱失败!余额不足!");
}
}
}
public class DrawTest
{
public static void main(String[] args)
{
// 创建一个账户
Account acct = new Account("15158117453", 1000);
// 模拟两个线程对同一个账户取钱
new DrawThread("LinkinPark", acct, 800).start();
new DrawThread("NightWish", acct, 800).start();
}
}
运行上面的程序,控制台结果如下:
很明显出错了,正常的应该是LinkinPark可以取钱,取完钱余额变成了200,NightWish就不能再次取钱了。。。
既然如此,那么如何避免以上的问题呢?加锁。具体有2种方式:1,同步代码库 2,同步方法
使用第一种方式解决上面的问题,我们只需要修改取钱的那个线程类,给取钱的那个线程类中的那个账户添加一个同步监视器就可以了。
/**
*
* @version 1L
* @author LinkinPark
* @since 2015-2-4
* @motto 梦似烟花心似水,同学少年不言情
* @desc ^ 取钱的线程类:“加锁 → 修改 → 释放锁”
*/
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() + "取钱失败!余额不足!");
}
}
//同步代码块结束,该线程释放同步锁
}
}
现在结果正确了。
使用第2种方式解决上面的问题,代码如下:
/**
*
* @version 1L
* @author LinkinPark
* @since 2015-2-4
* @motto 梦似烟花心似水,同学少年不言情
* @desc ^ 账户类,封装账户编号、账户余额两个Field,另外还有取钱的方法
*/
public class Account
{
// 封装账户编号、账户余额两个Field
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;
}
}
/**
*
* @version 1L
* @author LinkinPark
* @since 2015-2-4
* @motto 梦似烟花心似水,同学少年不言情
* @desc ^ 取钱的线程类:“加锁 → 修改 → 释放锁”
*/
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);
}
}
关于上面这2种做法的总结:
第一种做法使用synchronized将run方法里的方法体修改成为同步代码块,去实现"加锁--修改完成--释放锁"的逻辑。这里有一个问题就是如何确定同步代码块中的那个同步监视器是那个对象呢?记住就可以了:这个对象就是可能被并发访问的共享资源。
第二种做法使用synchronized来修饰某个方法,使用这种方式不需要显式指定同步监视器,其实同步监视器也就是他自己了,也就是this。值得注意的是:synchronized关键字可以修饰方法,可以修饰代码块,但是不能修饰构造器和属性。加锁了以后效率肯定会受到影响的,所以我们要有选择的去针对那些操作存在竞争的资源的方法来加锁,不要随便加。
对比上面2种加锁的做法,我们不难发现,其实第2种的设计是比较好的,因为这种做法更符合面向对象设计规则。面向对象里面有一种流行的设计方式,叫做领域驱动设计(DDD),说白了就是说对每一个对象都实现良好的封装,关于操作这个对象的属性或者方法都应该写在这个对象里面,不应该写在别的类中。
- 释放同步监视器的锁定
经过前面的整理,我们已经知道了,任何线程在进入同步代码块或者是同步方法之前,都会先获得对同步监视器的锁定,那么何时才会去释放这个锁定呢:
1,代码执行结束
2,代码遇见了retrue,break
3,代码中出现了未处理的Error和Exception
4,代码执行同步监视器的wait方法。
注意的是:程序调用Thread的sleep方法和yield方法,或者其他线程调用了该线程的suspend和resume方法都不会释放同步监视器的锁。
- 最后介绍2个知识点,实际编码中使用到的情景并不多。
1,同步锁(Lock)
JDK1.5后,提供了一个Lock接口。Lock 实现提供了比使用 synchronized 方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的 Condition 对象。常用的实现类是ReentrantLock。API中是这么介绍ReentrantLock的:一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。
建议总是 立即实践,使用 lock 块来调用 try,在之前/之后的构造中,最典型的代码如下:
class X {
private final ReentrantLock lock = new ReentrantLock();
// ... public void m() {
lock.lock(); // block until condition holds
try {
// ... method body
} finally {
lock.unlock()
}
}
}
2,死锁
死锁就是说当2个线程互相等待对方来释放同步监视器。值得注意的是:死锁不报错的,整个程序不会异常,也不会给出任何的提示,只是所有的线程都处于阻塞状态,无法继续了。下面给出一个例子:
/**
*
* @version 1L
* @author LinkinPark
* @since 2015-2-4
* @motto 梦似烟花心似水,同学少年不言情
* @desc ^ 出现死锁的一个例子
*/
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();
}
}