线程安全问题
多个线程同时访问同一资源的时候有可能会出现信息不一致的情况,这是线程安全问题,下面是一个例子,
Account.class , 定义一个Account模型
1 package threads.sync; 2 3 4 public class Account { 5 private String accountNo; 6 private double balance; 7 public Account() {} 8 9 10 public Account(String accountNo, double balance) { 11 this.accountNo = accountNo; 12 this.balance = balance; 13 } 14 15 public String getAccountNo() { 16 return accountNo; 17 } 18 19 public void setAccountNo(String accountNo) { 20 this.accountNo = accountNo; 21 } 22 23 public double getBalance() { 24 return balance; 25 } 26 27 public void setBalance(double balance) { 28 this.balance = balance; 29 } 30 31 32 public int hashCode() { 33 return accountNo.hashCode(); 34 } 35 36 public boolean equals(Object obj) { 37 if (this == obj) return true; 38 if (obj != null && obj.getClass() == Account.class) { 39 Account target = (Account)obj; 40 return target.getAccountNo().equals(accountNo); 41 } 42 return false; 43 } 44 }
DrawThread.class ,定义一个取钱类,用来操作Account
1 package threads.sync; 2 3 public class DrawThread extends Thread { 4 private Account account; 5 private double drawAmount; 6 public DrawThread(String name, Account account, double drawAmount) { 7 super(name); 8 this.setAccount(account); 9 this.setDrawAmount(drawAmount); 10 } 11 public Account getAccount() { 12 return account; 13 } 14 public void setAccount(Account account) { 15 this.account = account; 16 } 17 public double getDrawAmount() { 18 return drawAmount; 19 } 20 public void setDrawAmount(double drawAmount) { 21 this.drawAmount = drawAmount; 22 } 23 24 public void run() { 25 if (account.getBalance() >= drawAmount) { 26 System.out.println(getName()+" draw money: "+drawAmount); 27 try { 28 Thread.sleep(1); 29 } catch (InterruptedException e) { 30 e.printStackTrace(); 31 } 32 account.setBalance(account.getBalance() - drawAmount); 33 System.out.println(getName()+" balance : "+account.getBalance()); 34 } else { 35 System.out.println("failed for insufficient balance "); 36 } 37 } 38 }
DrawTest.class , 写一个测试类
1 package threads.sync; 2 3 public class DrawTest { 4 public static void main(String[] args) { 5 Account acc = new Account("123456",1000); 6 new DrawThread("Thread-A",acc,800).start(); 7 new DrawThread("Thread-B",acc,800).start(); 8 } 9 }
执行结果,
1 Thread-B draw money: 800.0 2 Thread-A draw money: 800.0 3 Thread-B balance : 200.0 4 Thread-A balance : -600.0
可见这里出现了逻辑错误,B线程取出800元后,账户里应该只剩下200元,但是接着A线程却也取出了800元,而且最终账户余额还成了负数,显然是不对的。
造成上面错误的过程如下,当B线程执行到DreadThread类的第28行时,已经成功取出了800元,然后进入了sleep状态,没有继续下面的扣除余额的动作;
此时JVM调度器将CPU切换到A线程执行,由于此时余额尚未扣除,A也能取出800元,之后A也进入sleep状态。
接着B线程从sleep状态经历了1毫秒之后,进入了就绪状态,接着获取了CPU进入了运行状态,进行了后面的动作,余额变成了200元。
最后A线程也醒来并获得继续运行机会,也做了一次扣款,结果余额变成了-600元(200-800)
以上便是一个典型的线程安全问题。
同步代码块
解决上面线程安全问题的一种办法是同步代码块,使得一块代码同一时间只能在一个线程中执行,也就是常说的同步监视器原理。同步代码格式如下,
synchronized(obj)
{
/*
* 需要同步的代码块
*/
}
这表示JVM使用obj对象作为同步监视器(通常使用被并发访问的对象),线程执行这段代码之前,必须先获取对同步监视器的锁定。
下面是一个用account对象作为同步监视器的例子,
其他类用前面例子不变,唯一需要修改的是DrawThread.class
1 package threads.sync; 2 3 public class DrawThread extends Thread { 4 private Account account; 5 private double drawAmount; 6 public DrawThread(String name, Account account, double drawAmount) { 7 super(name); 8 this.setAccount(account); 9 this.setDrawAmount(drawAmount); 10 } 11 public Account getAccount() { 12 return account; 13 } 14 public void setAccount(Account account) { 15 this.account = account; 16 } 17 public double getDrawAmount() { 18 return drawAmount; 19 } 20 public void setDrawAmount(double drawAmount) { 21 this.drawAmount = drawAmount; 22 } 23 24 public void run() { 25 26 synchronized(account) { 27 if (account.getBalance() >= drawAmount) { 28 System.out.println(getName()+" draw money: "+drawAmount); 29 try { 30 Thread.sleep(1); 31 } catch (InterruptedException e) { 32 e.printStackTrace(); 33 } 34 account.setBalance(account.getBalance() - drawAmount); 35 System.out.println(getName()+" balance : "+account.getBalance()); 36 } else { 37 System.out.println("failed for insufficient balance "); 38 } 39 } 40 } 41 }
可以看到只是在线程执行体中加了synchronized(account) { }来将一块代码锁定,保证同一时间这段代码只能被一个线程执行。
执行结果,可以看到线程B去取款时已经没有足够余额了,所以失败,这与我们的设计初衷是相符的。
1 Thread-A draw money: 800.0 2 Thread-A balance : 200.0 3 failed for insufficient balance
同步方法
同步方法与同步代码块非常相似,只不过同步方法是将整个方法修饰为安全的线程访问方法,注意不能修饰static方法。
同步方法的监视器是this,即调用该方法的对象。不需要显示地指定监视器。
下面的是同步方法的例子,在Accont.class中,我们新加入一个同步方法draw,用来替代原来DrawThread中取款的线程执行体,
1 package threads.sync; 2 3 public class Account { 4 private String accountNo; 5 private double balance; 6 public Account() {} 7 8 9 public Account(String accountNo, double balance) { 10 this.accountNo = accountNo; 11 this.balance = balance; 12 } 13 14 public String getAccountNo() { 15 return accountNo; 16 } 17 18 public void setAccountNo(String accountNo) { 19 this.accountNo = accountNo; 20 } 21 22 public double getBalance() { 23 return balance; 24 } 25 26 public void setBalance(double balance) { 27 this.balance = balance; 28 } 29 30 31 public int hashCode() { 32 return accountNo.hashCode(); 33 } 34 35 public boolean equals(Object obj) { 36 if (this == obj) return true; 37 if (obj != null && obj.getClass() == Account.class) { 38 Account target = (Account)obj; 39 return target.getAccountNo().equals(accountNo); 40 } 41 return false; 42 } 43 44 public synchronized void draw(double drawAmount) { 45 if ( balance >= drawAmount) { 46 System.out.println(Thread.currentThread().getName()+" draw money: "+drawAmount); 47 try { 48 Thread.sleep(1); 49 } catch (InterruptedException e) { 50 e.printStackTrace(); 51 } 52 balance -= drawAmount; 53 System.out.println(Thread.currentThread().getName()+" balance : "+balance); 54 } else { 55 System.out.println("failed for insufficient balance "); 56 } 57 } 58 59 }
修改DrawThread.class,我们直接在线程执行体中调用Account.class中的同步方法,调用同步方法的对象account将成为同步监视器被加锁,
1 package threads.sync; 2 3 public class DrawThread extends Thread { 4 private Account account; 5 private double drawAmount; 6 public DrawThread(String name, Account account, double drawAmount) { 7 super(name); 8 this.setAccount(account); 9 this.setDrawAmount(drawAmount); 10 } 11 public Account getAccount() { 12 return account; 13 } 14 public void setAccount(Account account) { 15 this.account = account; 16 } 17 public double getDrawAmount() { 18 return drawAmount; 19 } 20 public void setDrawAmount(double drawAmount) { 21 this.drawAmount = drawAmount; 22 } 23 24 public void run() { 25 // account对象将作为同步监视器被加锁 26 account.draw(drawAmount); 27 } 28 }
执行结果,
1 Thread-A draw money: 800.0 2 Thread-A balance : 200.0 3 failed for insufficient balance
释放同步监视器的锁定
任何线程在进入同步代码块或同步方法之前,需要先获取同步监视器的锁定,最终会释放锁定(但不是显示地释放)。那么在什么情况下同步监视器锁定会被线程释放呢?
- 当前线程的同步方法,同步代码块结束,当前线程释放同步监视器
- 当前线程在同步方法,同步代码块中遇到break,return终止了执行的时候,当前线程会释放同步监视器
- 当前线程在同步方法,同步代码块中出现了未处理的Error或Exception,导致无法继续执行下去,当前线程会释放同步监视器
- 当前线程在执行同步方法,同步代码块时,程序执行了同步监视器对象的wait方法,则当前线程暂停,并释放同步监视器
以下情况线程不会释放同步监视器,
- 线程在执行同步方法,同步代码块时,程序调用sleep(), yield()方法来暂停当前线程时,当前线程不会释放同步监视器
- 线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器。
同步锁
同步锁可以显示地获取锁和释放锁,ReentrantLock是最常使用的同步锁。结合try .. finally {} 机制,可以确保同步锁在必要时得到释放。
JAVA8中提供了一个StampedLock类,可为读写操作提供不同模式,例如Reading, Writing, ReadingOptimitic...
下面是一个同步锁的例子,修改前面的Account.class,引入同步锁进行加锁和释放锁,其他类保持不变,
1 package threads.sync; 2 3 import java.util.concurrent.locks.ReentrantLock; 4 5 public class Account { 6 private final ReentrantLock lock = new ReentrantLock(); 7 private String accountNo; 8 private double balance; 9 public Account() {} 10 11 12 public Account(String accountNo, double balance) { 13 this.accountNo = accountNo; 14 this.balance = balance; 15 } 16 17 public String getAccountNo() { 18 return accountNo; 19 } 20 21 public void setAccountNo(String accountNo) { 22 this.accountNo = accountNo; 23 } 24 25 public double getBalance() { 26 return balance; 27 } 28 29 public void setBalance(double balance) { 30 this.balance = balance; 31 } 32 33 34 public int hashCode() { 35 return accountNo.hashCode(); 36 } 37 38 public boolean equals(Object obj) { 39 if (this == obj) return true; 40 if (obj != null && obj.getClass() == Account.class) { 41 Account target = (Account)obj; 42 return target.getAccountNo().equals(accountNo); 43 } 44 return false; 45 } 46 47 public void draw(double drawAmount) { 48 lock.lock(); 49 try { 50 if ( balance >= drawAmount) { 51 System.out.println(Thread.currentThread().getName()+" draw money: "+drawAmount); 52 try { 53 Thread.sleep(1); 54 } catch (InterruptedException e) { 55 e.printStackTrace(); 56 } 57 balance -= drawAmount; 58 System.out.println(Thread.currentThread().getName()+" balance : "+balance); 59 } else { 60 System.out.println("failed for insufficient balance "); 61 } 62 } finally { 63 lock.unlock(); 64 } 65 } 66 67 }
执行结果,在DrawThread.class中,通过调用account的draw方法,使用ReentrantLock的对象对取款操作进行同步锁操作,
1 Thread-A draw money: 800.0 2 Thread-A balance : 200.0 3 failed for insufficient balance
死锁
当两个线程互相等待对方释放同步监视器时就会发生死锁。
死锁很容易发生,尤其在有多个同步监视器的时候,下面就是一个例子,
A.class
1 package threads.sync; 2 3 public class A { 4 public synchronized void foo(B b) { 5 System.out.println(Thread.currentThread().getName()+": entered A.foo()"); 6 try { 7 Thread.sleep(200); 8 } catch (InterruptedException e) { 9 e.printStackTrace(); 10 } 11 System.out.println(Thread.currentThread().getName()+": trying to call B.last() "); 12 b.last(); 13 } 14 15 public synchronized void last() { 16 System.out.println("A.last() executing"); 17 } 18 }
B.class
1 package threads.sync; 2 3 public class B { 4 public synchronized void bar(A a) { 5 System.out.println(Thread.currentThread().getName()+": entered B.bar()"); 6 try { 7 Thread.sleep(200); 8 } catch (InterruptedException e) { 9 e.printStackTrace(); 10 } 11 System.out.println(Thread.currentThread().getName()+": trying to call A.last() "); 12 a.last(); 13 } 14 public synchronized void last() { 15 System.out.println("B.last() executing"); 16 } 17 }
A和B两个类中的方法都是同步方法,通过线程执行体调用的话会对调用对象加锁(将调用对象作为同步监视器),例如下面这样,
DeadLock.class
1 package threads.sync; 2 3 public class DeadLock implements Runnable { 4 A a = new A(); 5 B b = new B(); 6 public void init() { 7 Thread.currentThread().setName("main Thread"); 8 a.foo(b); 9 System.out.println("after entering main Thread"); 10 } 11 12 @Override 13 public void run() { 14 Thread.currentThread().setName("sub Thread"); 15 b.bar(a); 16 System.out.println("after entering sub Thread"); 17 } 18 19 public static void main(String[] args) { 20 DeadLock dl = new DeadLock(); 21 new Thread(dl).start(); 22 dl.init(); 23 } 24 25 }
执行结果,
1 main Thread: entered A.foo() 2 sub Thread: entered B.bar() 3 sub Thread: trying to call A.last() 4 main Thread: trying to call B.last()
上面的执行结果到第4行的时候并没有结束,而是有两个线程处于阻塞状态,且这两个线程各自锁定一个同步监视器,同时又各自在请求对方的同步监视器,因此就陷入了死锁状态,具体过程如下,
- init()首先被执行(先后顺序随机),
- main 线程中调用a对象的foo方法,则main线程对a对象锁定,当main线程执行到foo方法中的第7行时,进入sleep状态(main线程不会释放同步监视器a),CPU切换到sub线程,
- sub线程中调用了b 对象的bar方法,于是sub 线程对b对象锁定,当sub线程执行到bar方法第7行时,也进入sleep状态(sub线程不会释放同步监视器b),
- main线程的由于先进入sleep所以会先醒来继续执行到foo方法第12行时,尝试调用b对象的同步方法last(),需要先锁定同步监视器b,
- 由于此时sub线程还处于sleep状态,并未释放同步监视器b,所以main线程将因此阻塞(依然不会释放同步监视器a),
- 当sub线程醒来之后,执行到bar方法第12行,尝试调用a对象的同步方法last(),需要先锁定同步监视器a,
- 由于此时main线程还处于阻塞状态并且锁定了同步监视器a,所以sub线程也会因此进入阻塞状态(依然不会释放同步监视器b),
- 至此,就形成了main线程持有同步监视器a,请求获取同步监视器b,而sub线程持有同步监视器b,请求获取同步监视器a的死锁局面
对于线程同步,出于性能方面考虑,有如下原则,(参考自阿里巴巴Java开发手册)
6. 【强制】高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能
锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。
7. 【强制】对多个资源、数据库表、对象同时加锁时,需要保持一致的加锁顺序,否则可能会造
成死锁。
说明:线程一需要对表A、B、C依次全部加锁后才可以进行更新操作,那么线程二的加锁顺序
也必须是A、B、C,否则可能出现死锁。
8. 【强制】并发修改同一记录时,避免更新丢失,要么在应用层加锁,要么在缓存加锁,要么在
数据库层使用乐观锁,使用version作为更新依据。
说明:如果每次访问冲突概率小于20%,推荐使用乐观锁,否则使用悲观锁。乐观锁的重试次
数不得小于3次。