线程的同步
多线程编程容易突然出现错误,这是因为系统的线程调度具有一定的随机性,也说明了编程不当。在使用多线程的时候,必须要保证线程安全。
线程安全
当两个或两个以上的线程需要共享资源,它们需要某种方法来确定资源在某一刻仅被一个线程占用。达到此目的的过程叫做同步(synchronization)。
多线程共用一份资源时容易出现错误,看如下例子:
class TestThread implements Runnable{
int i = 100;
@Override
public void run() {
// TODO Auto-generated method stub
while(true){
System.out.println(Thread.currentThread().getName()+" "+i);
i--;
Thread.yield();
if(i < 0){
break;
}
}
}
}
public class TestYield
{
public static void main(String[] args) throws InterruptedException{
TestThread tt = new TestThread();
//生成两个thread对象,但是这两个thread对象共用同一个线程体tt
Thread ty1 = new Thread(tt);
Thread ty2 = new Thread(tt);
//通过setName方法为线程设置名字
ty1.setName("线程a");
//启动线程
ty1.start();
ty2.setName("线程b");
ty2.start();
}
}
输出结果:
线程a 100
线程b 100
线程a 99
线程b 98
...
线程a 5
线程b 4
线程a 3
线程b 2
线程a 1
线程b 0
从结果可以看出,线程a和线程b本应该交替执行,但是由于系统的线程调度具有一定的随机性,线程a和线程b都输出了100。
可能这个例子还不算典型。下面看一个现实中的例子。
考虑一个经典的问题:银行取钱,基本流程分为如下几个步骤:
- 用户输入账户、密码,系统判断用户的账户和密码是否匹配
- 用户输入取款金额
- 系统判断用户余额是否大于取款金额
- 余额 > 取款金额,取款成功,用户取出钞票;否则取款失败。
现在模拟最后三步操作,使用两条线程来模拟两个人分别在不同的取款机上对同一个账户并发进行取款。
下面先定义一个账户类,该账户类封装了账户编号和余额两个属性:
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 void setBalance(double balance) {
this.balance = balance;
}
//下面两个方法根据accountNo来计算Account的hashCode和判断equals
public int hashCode() {
return this.accountNor.hashCode();
}
public boolean equals(Object obj)
{
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 amount) {
super(name);
this.account = account;
this.drawAmount = amount;
}
//当多条线程修改同一个共享数据时,将涉及到数据安全问题。
public void run() {
if (this.account.getBalance() >= this.drawAmount) {
System.out.println(this.getName() + " : Success! draw : " + this.drawAmount);
try {
this.sleep(1);
} catch(InterruptedException e) {
e.printStackTrace();
}
account.setBalance(this.account.getBalance() - this.drawAmount);
System.out.println("balance : " + this.account.getBalance());
} else {
System.out.println(this.getName() + " : Fail...Not enough balance");
}
}
}
上面程序是一个非常简单的取钱逻辑。
下面创建一个账户,启动两个线程从该账户中取钱。
public class TestDraw {
public static void main(String[] args) {
//创建一个账户
Account account = new Account("1568856", 1000);
//模拟两个线程对同一个账户取钱
new DrawThread("A", account, 800).start();
new DrawThread("B", account, 800).start();
}
}
输出结果:
A : Success! draw : 800.0
balance : 200.0
B : Success! draw : 800.0
balance : -600.0
这个结果明显是不符合预期的,账户余额只有1000,却取出了1600,而且账户余额出现了负数,这不是银行希望的结果。
同步代码块
为什么会出现这种情况呢?这是因为run方法的方法体不具有同步安全性:程序中由两条并发的线程修改Account对象,而且系统恰好在run方法里。
为了解决这个问题,java的多线程引入了重点内容同步监视器来解决这个问题。
使用同步监视器的通用方法就是同步代码块,语法如下:
synchronized(obj) {
//此处代码就是同步代码块
}
synchronized后括号里的obj就是同步监视器。上面代码的含义是:线程开始执行同步代码块之前,必须先获得对同步监视器的锁定。
使用同步监视器的目的是:
防止两条线程对同一个共享资源进行并发访问重点内容,因此通常用可能被并发访问的共享资源当同步监视器(比如上面的Account对象)。
使用同步监视器的逻辑是:
加锁 ——> 修改 ——> 修改完成 ——> 释放锁
任何线程想修改指定资源之前,首先对该资源加锁,在加锁期间其他线程无法修改该资源,修改完成后,该线程释放对资源的锁定,然后其他线程才能访问或修改这个资源。通过这种方式就可以保证在任一时刻只有一条线程可以进入修改共享资源的代码区(同步代码块,也被称作临界区),所以同一时刻仅仅有一条线程处于临界区内,从而保证了线程的安全性。
依照这个思路,修改上面的代码:
public class DrawThread extends Thread {
private Account account;
private double drawAmount;
public DrawThread(String name, Account account, double amount) {
super(name);
this.account = account;
this.drawAmount = amount;
}
public void run() {
synchronized (this.account) {
if (this.account.getBalance() >= this.drawAmount) {
System.out.println(this.getName() + " : Success! draw : "
+ this.drawAmount);
try {
this.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
//修改余额
account.setBalance(this.account.getBalance()
- this.drawAmount);
System.out.println("balance : " + this.account.getBalance());
} else {
System.out.println(this.getName()
+ " 取钱失败,余额不足");
}
}
//同步代码库结束,该线程释放同步锁
}
}
同步方法
与同步代码块对应的,Java的多线程安全还提供了同步方法。
-
同步方法就是使用synchronized关键字来修饰某个方法,该方法称为同步方法。
-
对于同步方法而言,无需显式指定同步监视器,==同步方法的同步监视器就是this,也就是对象本身==。
- synchronized关键字可以修饰方法,可以修饰代码块,但是不能修饰构造器,属性等。
不可变类总是线程安全的,因为它的对象状态不可改变。但可变类需要额外的方法来保证其线程安全。
例如上面的Account就是一个可变类,它的两个属性都可变。下面我们将Account类对balance的访问设置成线程安全的,那么程序只要把修改的balance方法修改成同步方法。
修改Account,增加一个用synchronized关键字修饰的draw方法:
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 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() +
"取钱失败!余额不足!");
}
}
public int hashCode()
{
return accountNo.hashCode();
}
public boolean equals(Object obj)
{
if (obj != null && obj.getClass() == Account.class)
{
Account target = (Account)obj;
return target.getAccountNo().equals(accountNo);
}
return false;
}
}
上面的代码还删去了balance属性的setter方法,因为账户余额不能随便修改。
同步方法的同步监视器是this,==因此对于同一个Account对象而言,任意时刻只能有一条线程获得对Account的锁定,然后进入draw方法执行取钱操作==,这样也可以保证多条线程并发取钱的线程安全。
因为Account类已经提供了draw方法,而且取消了setBalance()方法,所以还得修改DrawThread类:该类只需直接调用Account对象的draw方法来执行取钱操作。
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(drawAmount);
}
}
因为已经在Account类中使用synchronized保证了draw方法的线程安全性,所以在run方法里直接调用也没有任何问题。
这时的程序把draw方法定义在Account里,而不是在run方法里实现取钱逻辑,这种做法更符合面向对象。在面向对象里有一种流行的设计方式:==Domain Drive Design(领域驱动设计,DDD),这种方式认为每个类都应该是完备的领域对象。==例如Account代表账户,应该提供账户的相关方法,例如通过draw方法来执行取钱操作,而不是直接将setBalance方法暴露出来任人操作,这样才可以更好的保证Account对象的完整性、一致性。
可变类的线程安全是以降低程序的运行效率作为代价的,为了减少保证线程安全而带来的负面影响,可以采取以下策略:
-
不要对线程安全类的所有方法都进行同步(都用synchronized修饰),只对那些会改变竞争资源(共享资源)的方法进行同步。例如上面Account类的hashCode和equals等方法无需同步
-
如果可变类有两种运行环境:单线程和多线程环境,则应该为该类提供两种版本:线程不安全版本(用于在单线程环境中以保证性能),线程安全版本(在多线程环境中保证线程安全)
释放同步监视器的锁定
任何线程进入同步代码块,同步方法之前,必须先获得对同步监视器的锁定,那么何时会释放对同步监视器的锁定呢?
程序无法显式的释放对同步监视器的锁定,线程会在如下几种情况释放:
-
当前线程的同步方法、同步代码块执行结束
-
当前线程的同步方法、同步代码块遇到**break、retur**n终止了该方法的继续执行
-
当前线程的同步方法、同步代码块出现了未处理的Error或Exception,导致了该方法、代码块异常结束
-
当前线程的同步方法、同步代码块执行了同步监视器对象的wait方法
在下面情况下,线程不会释放同步监视器:
-
当前线程执行同步方法、同步代码块时,程序调用Thread.sleep(),Thread.yield()方法来暂停当前线程的执行
-
当前线程执行同步代码块、同步方法时,其他线程调用了当前线程的suspend方法将它挂起(当然我们应该尽量避免使用suspend和resume方法)
同步锁(Lock)
另一种线程同步的机制:它通过显式定义同步锁对象来实现同步,在这种机制下,同步锁应该使用Lock对象更适合。
Lock提供了比synchronized方法和代码块更广泛的锁定操作,Lock对象实现允许更灵活的结构,可以具有差别很大的属性,并可以支持多个相关的Condition对象。
Lock是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。不过,某些锁可能允许对共享资源进行并发访问,比如ReadWriteLock。
在实现线程安全的控制中,通常用ReentrantLock(可重用锁),使用该Lock对象可以显式的加锁、释放锁。
语法如下:
class X {
// 定义锁对象
private final ReentrantLock lock = new ReentranLock();
// 定义需要保证线程安全的方法
public void m() {
// 加锁
lock.lock();
try {
//需要保证线程安全的代码
// body
} finally {
// 使用finally来保证释放锁
lock.unlock();
}
}
}
根据Lock来修改上面的Account类:
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 void setAccountNo(String accountNo)
{
this.accountNo = accountNo;
}
public String getAccountNo()
{
return this.accountNo;
}
public double getBalance()
{
return this.balance;
}
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();
}
}
public int hashCode()
{
return accountNo.hashCode();
}
public boolean equals(Object obj)
{
if (obj != null && obj.getClass() == Account.class)
{
Account target = (Account)obj;
return target.getAccountNo().equals(accountNo);
}
return false;
}
}
-
使用Lock与使用同步方法有点相似,只是使用Lock时显式使用Lock对象作为同步锁,而使用同步方式时系统隐式的使用当前对象作为同步监视器,同样都符合“加锁 –> 访问 –>释放锁”的模式;而且使用Lock对象时每个Lock对象对应一个Account对象,同样能保证对于同一个Account对象,同一时刻只有一条线程进入临界区。
-
Lock提供了同步方法和同步代码块没有的其他功能,包括用于非块结构的tryLock方法、试图获取可中断锁lockInterruptibly方法、获取超时失效锁的tryLock(long, TimeUnit)方法
-
ReentrantLock具有重入性,也就是说线程可以对它已经加锁的ReentrantLock锁再次加锁,ReentrantLock对象会维持一个计数器来追踪lock方法的嵌套调用。线程在每次调用lock方法加锁后,必须显式的使用unlock来解锁。
死锁(DeadLock)
当两个线程相互等待对方释放同步监视器时就会发生死锁,java虚拟机没有检测,也没有采用措施来处理死锁情况,一旦出现死锁,整个程序既不会发生任何异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续运行。
死锁是很容易发生的,尤其是出现多个同步监视器的时候。
为充分理解死锁,观察它的行为是很有用的。下面的例子生成了两个类,A和B,分别有foo( )和bar( )方法。这两种方法在调用其他类的方法前有一个短暂的停顿。主类,名为Deadlock,创建了A和B的实例,然后启动第二个线程去设置死锁环境。foo( )和bar( )方法使用sleep( )强迫死锁现象发生。
class A
{
public synchronized void foo( B b )
{
System.out.println("当前线程名: " +
Thread.currentThread().getName() + " 进入了A实例的foo方法" );//@1
try
{
Thread.sleep(200);
}
catch (InterruptedException ex)
{
ex.printStackTrace();
}
System.out.println("当前线程名: " +
Thread.currentThread().getName() + " 企图调用B实例的last方法");//@3
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方法" );//@2
try
{
Thread.sleep(200);
}
catch (InterruptedException ex)
{
ex.printStackTrace();
}
System.out.println("当前线程名: "
+ Thread.currentThread().getName() + " 企图调用A实例的last方法");//@4
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();
}
}
输出结果:
当前线程名: 主线程 进入了A实例的foo方法
当前线程名: 副线程 进入了B实例的bar方法
当前线程名: 主线程 企图调用B实例的last方法
当前线程名: 副线程 企图调用A实例的last方法
从结果中可以看出:程序既无法向下执行,也不会抛出任何异常,只是都卡在两个对象的last方法那里,僵持着无法向下执行。
这是因为:
-
上面程序中A对象和B对象的方法都是同步方法,即:A对象和B对象都是同步锁。
-
程序中有两条线程在执行,一条线程的执行体是DeadLock类的run方法(副线程),另一条线程的执行体是DeadLock的init方法(主线程调用了init方法)。run方法让B对象调用bar方法,而init方法让A对象调用foo方法。
-
init方法首先执行,调用了A对象的foo方法,因为foo方法是一个同步方法,所以进入foo方法之前,主线程会首先对A对象加锁。执行到Thread.sleep(2000);时,主线程休眠200个毫秒
-
然后副线程开始执行,调用B对象的bar方法,同样的,由于bar方法也是一个同步方法,所以进入bar方法之前,副线程会首先对B对象加锁。执行到Thread.sleep(200);时,副线程休眠200个毫秒
-
接下来主线程继续向下执行直到b.last();,此处希望调用b的last方法;由于last方法也是一个同步方法,所以主线程在执行之前必须先对B对象加锁,但此时副线程正保持着B对象的锁,主线程无法对B对象加锁。所以b.last();无法执行,主线程进入阻塞状态。
-
接着副线程醒过来继续向下执行直到a.last();,此处希望调用a的last方法,由于last方法也是一个同步方法,所以在副线程执行之前必须先对A对象加锁,==但此时主线程正保持着A对象的锁,副线程无法对A对象加锁==。所以a.last();无法执行,副线程也进入阻塞状态。
-
此时的状况就是:主线程保持着A对象的同步锁,等待B对象解锁之后执行b.last();;副线程保持着B对象的同步锁,等待A对象解锁之后执行a.last(),两条线程互相等待对方先释放锁,谁也不让谁,所以就出现了死锁!
由于Thread类的suspend也很容易导致死锁,所以Java不推荐使用该方法来暂停线程的执行。