当多个线程随机操作一个数据的时候很容易出现“偶然性”的错误。
线程安全问题
假设现在有一个账户类。
public class Account {
private String accountNo;
private double balance;
public Account() {}
public Account(String accountNo, double balance){
this.accountNo = accountNo;
this.balance = 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;
}
}
之后还有一个取钱的线程类。
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() + " Succeeded in drawing, poping money. " + drawAmount);
try {
Thread.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
account.setBalance(account.getBalance() - drawAmount);
System.out.println("Balance is " + account.getBalance());
}else{
System.out.println(getName() + "Fail to draw due to insufficient balance.");
}
}
}
运行以下程序
public class Main {
public static void main(String[] args) {
Account acct = new Account("1234567", 1000);
new DrawThread("甲", acct, 800).start();
new DrawThread("乙", acct, 800).start();
}
}
多次运行之后得到如下运行结果。
取款金额出现了负数,这不是银行所期望的结果。这是多线程编程过程中出现的偶然错误(线程调度的不确定性)。
同步代码块
之所以出现如上的结果,是因为run()方法的方法体不具有同步安全性——程序中有两个并发线程在修改Account对象;而且系统恰好在sleep的时候进行了线程的切换,切换给另一个修改Account对象的线程。
为了解决这个问题,Java的多线程引入了同步监视器来解决这个问题。使用同步监视器的通用方法是同步代码块。同步代码块的格式如下:
synchronized(obj){
//此处的代码就是同步代码块
}
synchronized括号里的obj就是同步监视器。上面代码的含义是:线程开始执行同步代码块之前,必须先获得对同步监视器的锁定。
任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码执行完成后,该线程会释放对该同步监视器的锁定。
虽然Java程序允许用任何对象做同步监视器,但同步监视器的目的是阻止两个线程对同一个共享资源进行并发访问,因此推荐使用可能被并发访问的共享资源充当同步监视器。
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() + " Succeeded in drawing, poping money. " + drawAmount);
try {
Thread.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
account.setBalance(account.getBalance() - drawAmount);
System.out.println("Balance is " + account.getBalance());
}else{
System.out.println(getName() + "Fail to draw due to insufficient balance.");
}
}
//同步代码块结束,该线程释放同步锁。
}
}
在任何线程修改指定资源前首先应该对资源加锁,在加锁期间其他线程无法修改资源。当线程修改完成之后,该线程释放对该资源的锁定。通过这种方式可以保证并发线程在任一时刻只有一个线程可以进入修改共享资源的代码区(也被称为临界区),所以同一时刻最多只有一个线程处于临界区内,从而保证了线程的安全性。
同步方法
与同步代码块对应,Java的多线程安全支持还提供了同步方法。同步方法就是使用synchronized关键字来修饰某个方法,则成该方法为同步方法。对于synchronized关键字修饰的实例方法而言,无须显示指定同步监视器。同步方法的同步监视器是this。通过使用同步方法可以很方便的实现线程安全的类。
线程安全的类具有如下的特征:
- 该类的对象可以被多个线程安全地访问。
- 每个线程调用该对象的任意方法之后都将得到正确的结果。
- 每个线程调用该对象的任意方法之后,该对象依然保持合理的状态。
不可变的类总是线程安全的,因为它的对象状态不可改变;但可变对象需要额外的方法来保证其线程安全。例如上面的Account类就是一个可变的类,它的accountNo和balance两个成员变量都可以被改变,当两个线程同时修改Account对象的balance成员变量值时,程序就出现了异常。下面将Account类对balance的访问设置为线程安全的,那么只要把修改balance的方法变为同步方法即可。
public class Account {
private String accountNo;
private double balance;
public Account() {}
public Account(String accountNo, double balance){
this.accountNo = accountNo;
this.balance = balance;
}
public String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
public double getBalance() {
return balance;
}
public synchronized void draw(double drawAmount){
if(balance >= drawAmount){
System.out.println(Thread.currentThread().getName() + " Succeeded in drawing, poping money. " + drawAmount);
try {
Thread.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
balance -= drawAmount;
System.out.println("Balance is " + balance);
}else{
System.out.println(Thread.currentThread().getName() + "Fail to draw due to insufficient balance.");
}
}
}
上面的程序在Account类中增加了一个取钱的方法draw(),并使用了synchronized关键字进行修饰,把该方法变为了同步方法。该同步方法的同步监视器是this。因此对于同一个Account账户而言,任意时刻只能有一个线程获得对Account对象的锁定,然后进入draw()方法执行取钱操作——这样也可以保证多个线程并发取钱的线程安全。
synchronized关键字可以修饰方法,可以修饰代码块,但不能修饰成员变量和构造函数。
DrawThread类无须自己实现取钱的操作,而是直接调用account对象的draw方法来执行取钱的操作。由于使用了synchronized关键字修饰了draw方法,同步方法的同步监视器是this,而this总代表调用该方法的对象。在上面的示例当中调用draw()方法的对象是account,因此多个线程并发修改一个account前,必须对account对象加锁,这符合“加锁——修改——释放锁”的逻辑。
在Account里定义draw方法,而不是直接在run()方法中实现取钱逻辑,这种做法更符合面向对象的规则。在面向对象里有一种流行的设计方式:Domain Driven Design(领域驱动设计),这种方式认为每个类都应该是完备的领域对象。例如Account代表用户账户,应该提供用户账户的相关方法。通过draw方法来实现取钱的操作,而不是直接将setBalance方法暴露出来任人操作,这样才可以更好的保证Account对象的完整性和一致性。
可变类的线程安全是以降低程序的运行效率作为代价的,为了减少线程安全所带来的负面影响,程序可以采取以下的策略。
- 不要对线程安全类的所有方法进行同步,只对那些会改变竞争资源(竞争资源就是共享资源)的方法进行同步。
- 如果可变类有两种运行环境:单线程环境和多线程环境,则应该为该可变类提供两个版本,即线程不安全版本和线程安全版本。在单线程环境中使用线程不安全版本来保证性能,在多线程环境中使用线程安全版本。
JDK所提供的StringBuilder、StringBuffer就是为了照顾单线程环境和多线程环境所提供的类,在单线程环境下应该使用StringBuilder来保证较好的性能;当需要保证多线程安全时,就应该使用StringBuffer。
释放同步监视器的锁定
程序无法显示释放对同步监视器的锁定,线程会在如下几种情况下释放对同步监视器的锁定:
- 当前线程的同步方法、同步代码块执行结束,当前线程即释放同步监视器。
- 当前线程在同步代码块、同步方法中遇到break、return终止了该代码块、该方法的继续执行,当前线程将会释放同步监视器。
- 当前线程在同步代码块、同步代码块中出现了未处理的Error或Exception,导致了该代码块、该方法异常结束时,当前线程将会释放同步监视器。
- 当前线程执行同步代码块或同步方法时,程序执行了同步监视器的wait()方法,则当前线程暂停,并释放同步监视器。
在如下所示的情况下,线程不会释放同步监视器: - 线程执行同步代码块或同步方法时,程序调用Thread.sleep()、Thread.yield()方法来暂停当前线程的执行,当前线程不会释放同步监视器。
- 线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器。当然,程序应该尽量避免使用suspend()和resume()方法来控制线程。
同步锁
同步锁由Lock对象充当。Lock是控制多个线程对共享资源进行访问的工具。每次只有一个线程对Lock对象加锁,线程开始访问共享资源之前应获得Lock对象。
某些锁可能允许对共享资源并发访问,如ReadWriteLock(读写锁),Lock、ReadWriteLock是Java5提供的两个根接口,并为Lock提供ReentrantLock(可重入锁)实现类,为ReadWriteLock提供了ReentrantReadWriteLock实现类。
Java8新增了新型的StampedLock类,在大多数场景中它可以替代传统的ReentrantReadWriteLock。ReentrantReadWriteLock为读写提供了三种锁模式:Writing、ReadingOptimistic、Reading。
通常使用ReentrantLock的代码格式如下:
class X{
private final ReentrantLock lock = new ReentrantLock();
//定义需要保证线程安全的方法
public void m(){
lock.lock();
try{
//需要保证线程安全的代码
//...method body
}finally{
lock.unlock();
}
}
}
使用ReentrantLock对象来进行同步,加锁和释放锁出现在不同的作用范围内,通常建议使用finally块来确保在必要时释放锁。
public class Account {
//定义锁对象
private final ReentrantLock lock = new ReentrantLock();
private String accountNo;
private double balance;
public Account() {}
public Account(String accountNo, double balance){
this.accountNo = accountNo;
this.balance = balance;
}
public String getAccountNo() {
return accountNo;
}
public void setAccountNo(String accountNo) {
this.accountNo = accountNo;
}
public double getBalance() {
return balance;
}
public synchronized void draw(double drawAmount){
//加锁
lock.lock();
try {
if(balance >= drawAmount){
System.out.println(Thread.currentThread().getName() + " Succeeded in drawing, poping money. " + drawAmount);
try {
Thread.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
balance -= drawAmount;
System.out.println("Balance is " + balance);
}else{
System.out.println(Thread.currentThread().getName() + "Fail to draw due to insufficient balance.");
}
} finally{
//修改完成释放锁
lock.unlock();
}
}
}
使用Lock与使用同步方法有点相似,只是使用Lock时显示使用Lock对象作为同步锁,而使用同步方法时系统隐式使用当前对象作为同步监视器,同样符合“加锁——修改——释放锁”的操作模式,而且使用Lock对象时每个Lock对象对应一个Account对象,一样可以保证对于同一个Account对象,同一时刻只能有一个线程进入临界区。
同步方法或同步代码块使用与竞争资源相关的、隐式的同步监视器,并且强制要求加锁和释放锁要出现在同一个块结构中,而且当获取了多个锁的时候,它们必须以相反的顺序释放,且必须在与所有锁被获取时相同的范围内释放所有锁。
Lock提供了同步方法和同步代码块所没有的其他功能,包括用于非块结构的tryLock()方法,以及视图获取可中断锁的lockInterruptibly()方法,还有获取超时失效锁的tryLock(long TimeUnit)方法。
ReentrantLock锁具有可重入性,也就是说,一个线程可以对已被加锁的ReentrantLock锁再次加锁,ReentrantLock对象会维持一个计数器来追踪lock()方法的嵌套使用,线程在每次调用lock()加锁后,必须显示调用unlock()方法来释放锁,所以一段被锁保护的代码可以调用另一个被相同锁保护的方法。
死锁
当两个线程相互等待对方释放同步监视器当前就会发生死锁。Java虚拟机没有检测,也没有采取措施来处理死锁的情况。所以多线程编程应该尽量死锁出现的情况。一旦出现死锁,整个程序既不会发生任何异常,也不会给出任何提示,只是所有的进程处于阻塞状态,无法继续。
public class A {
public synchronized void foo(B b){
System.out.println(Thread.currentThread().getName() + " enters instance method of A(foo).");
try {
Thread.sleep(200);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " is trying to take B's method last().");
}
public synchronized void last(){
System.out.println("Interior of method A.");
}
}
public class B {
public synchronized void bar(A a){
System.out.println(Thread.currentThread().getName() + " enters instance method of B(bar).");
try {
Thread.sleep(200);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " is trying to take A's method last().");
}
public synchronized void last(){
System.out.println("Interior of method B.");
}
}
public class DeadLock implements Runnable{
A a = new A();
B b = new B();
public void init(){
Thread.currentThread().setName("Main Thread");
a.foo(b);
System.out.println("After entering main thread.");
}
public void run() {
Thread.currentThread().setName("Vice Thread");
b.bar(a);
System.out.println("After entering vice thread.");
}
}
public static void main(String[] args) {
DeadLock dl = new DeadLock();
new Thread(dl).start();
dl.init();
}
Thread类的suspend()方法很容易导致死锁,所以Java不再推荐使用该方法来暂停线程的执行。